Java 高并发三:Java内存模型和线程安全详解

网上很多资料在描述Java内存模型的时候,都会介绍有一个主存,然后每个工作线程有自己的工作内存。数据在主存中会有一份,在工作内存中也有一份。工作内存和主存之间会有各种原子操作去进行同步。

下图来源于这篇Blog

但是由于Java版本的不断演变,内存模型也进行了改变。本文只讲述Java内存模型的一些特性,无论是新的内存模型还是旧的内存模型,在明白了这些特性以后,看起来也会更加清晰。

1. 原子性

原子性是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其它线程干扰。
一般认为cpu的指令都是原子操作,但是我们写的代码就不一定是原子操作了。

比如说i++。这个操作不是原子操作,基本分为3个操作,读取i,进行+1,赋值给i。

假设有两个线程,当第一个线程读取i=1时,还没进行+1操作,切换到第二个线程,此时第二个线程也读取的是i=1。随后两个线程进行后续+1操作,再赋值回去以后,i不是3,而是2。显然数据出现了不一致性。

再比如在32位的JVM上面去读取64位的long型数值,也不是一个原子操作。当然32位JVM读取32位整数是一个原子操作。

2. 有序性

在并发时,程序的执行可能就会出现乱序。

计算机在执行代码时,不一定会按照程序的顺序来执行。

class OrderExample {
 int a = 0;
 boolean flag = false;
 public void writer()
 {
 a = 1;
 flag = true;
 }
 public void reader()
 {
 if (flag)
 {
 int i = a +1;
 }
 }
 }

比如上述代码,两个方法分别被两个线程调用。按照常理,写线程应该先执行a=1,再执行flag=true。当读线程进行读的时候,i=2;

但是因为a=1和flag=true,并没有逻辑上的关联。所以有可能执行的顺序颠倒,有可能先执行flag=true,再执行a=1。这时当flag=true时,切换到读线程,此时a=1还没有执行,那么读线程将i=1。

当然这个不是绝对的。是有可能会发生乱序,有可能不发生。

那么为什么会发生乱序呢?这个要从cpu指令说起,Java中的代码被编译以后,最后也是转换成汇编码的。

一条指令的执行是可以分为很多步骤的,假设cpu指令分为以下几步

  1. 取指 IF
  2. 译码和取寄存器操作数 ID
  3. 执行或者有效地址计算 EX
  4. 存储器访问 MEM
  5. 写回 WB

假设这里有两条指令

一般来说我们会认为指令是串行执行的,先执行指令1,然后再执行指令2。假设每个步骤需要消耗1个cpu时间周期,那么执行这两个指令需要消耗10个cpu时间周期,这样做效率太低。事实上指令都是并行执行的,当然在第一条指令在执行IF的时候,第二条指令是不能进行IF的,因为指令寄存器等不能被同时占用。所以就如上图所示,两条指令是一种相对错开的方式并行执行。当指令1执行ID的时候,指令2执行IF。这样只用6个cpu时间周期就执行了两个指令,效率比较高。

按照这个思路我们来看下A=B+C的指令是如何执行的。

如图所示,ADD操作时有一个空闲(X)操作,因为当想让B和C相加的时候,在图中ADD的X操作时,C还没从内存中读取(当MEM操作完成时,C才从内存中读取。这里会有一个疑问,此时还没有回写(WB)到R2中,怎么会将R1与R1相加。那是因为在硬件电路当中,会使用一种叫“旁路”的技术直接把数据从硬件当中读取出来,所以不需要等待WB执行完才进行ADD)。所以ADD操作中会有一个空闲(X)时间。在SW操作中,因为EX指令不能和ADD的EX指令同时进行,所以也会有一个空闲(X)时间。

接下来举个稍微复杂点的例子

a=b+c
d=e-f

对应的指令如下图

原因和上面的类似,这里就不分析了。我们发现,这里的X很多,浪费的时间周期很多,性能也被影响。有没有办法使X的数量减少呢?

我们希望用一些操作把X的空闲时间填充掉,因为ADD与上面的指令有数据依赖,我们希望用一些没有数据依赖的指令去填充掉这些因为数据依赖而产生的空闲时间。

我们将指令的顺序进行了改变

改变了指令顺序以后,X被消除了。总体的运行时间周期也减少了。

指令重排可以使流水线更加顺畅

当然指令重排的原则是不能破坏串行程序的语义,例如a=1,b=a+1,这种指令就不会重排了,因为重排的串行结果和原先的不同。

指令重排只是编译器或者CPU的优化一种方式,而这种优化就造成了本章一开始程序的问题。

如何解决呢?用volatile关键字,这个后面的系列会介绍到。

3. 可见性

可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。

可见性问题可能有各个环节产生。比如刚刚说的指令重排也会产生可见性问题,另外在编译器的优化或者某些硬件的优化都会产生可见性问题。

比如某个线程将一个共享值优化到了内存中,而另一个线程将这个共享值优化到了缓存中,当修改内存中值的时候,缓存中的值是不知道这个修改的。

比如有些硬件优化,程序在对同一个地址进行多次写时,它会认为是没有必要的,只保留最后一次写,那么之前写的数据在其他线程中就不可见了。

总之,可见性的问题大多都源于优化。

接下来看一个Java虚拟机层面产生的可见性问题

问题来自于一个Blog

package edu.hushi.jvm;

/**
 *
 * @author -10
 *
 */
public class VisibilityTest extends Thread {

 private boolean stop;

 public void run() {
 int i = 0;
 while(!stop) {
  i++;
 }
 System.out.println("finish loop,i=" + i);
 }

 public void stopIt() {
 stop = true;
 }

 public boolean getStop(){
 return stop;
 }
 public static void main(String[] args) throws Exception {
 VisibilityTest v = new VisibilityTest();
 v.start();

 Thread.sleep(1000);
 v.stopIt();
 Thread.sleep(2000);
 System.out.println("finish main");
 System.out.println(v.getStop());
 }

}

代码很简单,v线程一直不断的在while循环中i++,直到主线程调用stop方法,改变了v线程中的stop变量的值使循环停止。
看似简单的代码运行时就会出现问题。这个程序在 client 模式下是能停止线程做自增操作的,但是在 server 模式先将是无限循环。(server模式下JVM优化更多)

64位的系统上面大多都是server模式,在server模式下运行:

finish main
true

只会打印出这两句话,而不会打印出finish loop。可是能够发现stop的值已经是true了。
该Blog作者用工具将程序还原为汇编代码

这里只截取了一部分汇编代码,红色部分为循环部分,可以清楚得看到只有在0x0193bf9d才进行了stop的验证,而红色部分并没有取stop的值,所以才进行了无限循环。

这是JVM优化后的结果。如何避免呢?和指令重排一样,用volatile关键字。

如果加入了volatile,再还原为汇编代码就会发现,每次循环都会get一下stop的值。

接下来看一些在“Java语言规范”中的示例

上图说明了指令重排将会导致结果不同。

上图使r5=r2的原因是,r2=r1.x,r5=r1.x,在编译时直接将其优化成r5=r2。最后导致结果不同。

4. Happen-Before

  1. 程序顺序原则:一个线程内保证语义的串行性
  2. volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性
  3. 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前
  4. 传递性:A先于B,B先于C,那么A必然先于C
  5. 线程的start()方法先于它的每一个动作
  6. 线程的所有操作先于线程的终结(Thread.join())
  7. 线程的中断(interrupt())先于被中断线程的代码
  8. 对象的构造函数执行结束先于finalize()方法
  9. 这些原则保证了重排的语义是一致的。

5. 线程安全的概念

指某个函数、函数库在多线程环境中被调用时,能够正确地处理各个线程的局部变量,使程序功能正确完成。

比如最开始所说的i++的例子

就会导致线程不安全。

关于线程安全的详情使用,请参考以前写的这篇Blog,或者关注后续系列,也会谈到相关内容

时间: 2016-09-08

java中volatile不能保证线程安全(实例讲解)

今天打了打代码研究了一下java的volatile关键字到底能不能保证线程安全,经过实践,volatile是不能保证线程安全的,它只是保证了数据的可见性,不会再缓存,每个线程都是从主存中读到的数据,而不是从缓存中读取的数据,附上代码如下,当synchronized去掉的时候,每个线程的结果是乱的,加上的时候结果才是正确的. /** * * 类简要描述 * * <p> * 类详细描述 * </p> * * @author think * */ public class Volatil

Java线程安全的计数器简单实现代码示例

前几天工作中一段业务代码需要一个变量每天从1开始递增.为此自己简单的封装了一个线程安全的计数器,可以让一个变量每天从1开始递增.当然了,如果项目在运行中发生重启,即便日期还是当天,还是会从1开始重新计数.所以把计数器的值存储在数据库中会更靠谱,不过这不影响这段代码的价值,现在贴出来,供有需要的人参考. package com.hikvision.cms.rvs.common.util; import java.text.SimpleDateFormat; import java.util.Arr

Java 集合中的类关于线程安全

Java集合中那些类是线程安全的 线程安全类 在集合框架中,有些类是线程安全的,这些都是jdk1.1中的出现的.在jdk1.2之后,就出现许许多多非线程安全的类. 下面是这些线程安全的同步的类: vector:就比arraylist多了个同步化机制(线程安全),因为效率较低,现在已经不太建议使用.在web应用中,特别是前台页面,往往效率(页面响应速度)是优先考虑的. statck:堆栈类,先进后出 hashtable:就比hashmap多了个线程安全 enumeration:枚举,相当于迭代器

Java线程安全与非线程安全解析

ArrayList和Vector有什么区别?HashMap和HashTable有什么区别?StringBuilder和StringBuffer有什么区别?这些都是Java面试中常见的基础问题.面对这样的问题,回答是:ArrayList是非线程安全的,Vector是线程安全的:HashMap是非线程安全的,HashTable是线程安全的:StringBuilder是非线程安全的,StringBuffer是线程安全的.因为这是昨晚刚背的<Java面试题大全>上面写的.此时如果继续问:什么是线程安全

实例解析Java中的synchronized关键字与线程安全问题

首先来回顾一下synchronized的基本使用: synchronized代码块,被修饰的代码成为同步语句块,其作用的范围是调用这个代码块的对象,我们在用synchronized关键字的时候,能缩小代码段的范围就尽量缩小,能在代码段上加同步就不要再整个方法上加同步.这叫减小锁的粒度,使代码更大程度的并发. synchronized方法,被修饰的方法成为同步方法,其作用范围是整个方法,作用对象是调用这个方法的对象. synchronized静态方法,修饰一个static静态方法,其作用范围是整个

实例解析Java中的构造器初始化

1.初始化顺序 当Java创建一个对象时,系统先为该对象的所有实例属性分配内存(前提是该类已经被加载过了),接着程序开始对这些实例属性执行初始化,其初始化顺序是:先执行初始化块或声明属性时制定的初始值,再执行构造器里制定的初始值. 在类的内部,变量定义的先后顺序决定了初始化的顺序,即时变量散布于方法定义之间,它们仍就会在任何方法(包括构造器)被调用之前得到初始化. class Window { Window(int maker) { System.out.println("Window(&quo

实例讲解Java中的synchronized

一.使用场景 在负责后台开发的时候,很多时候都是提供接口给前端开发人员去调用,会遇到这样的场景: 需要提供一个领奖接口,每个用户名只能领取一次,我们可以将成功领取的用户在数据库用个标记保存起来.如果这个用户再来领取的时候,查询数据库看该用户是否领取过. 但是问题来了,假设用户手速很快,极短时间内点了两次领奖按钮(前端没有进行控制,我们也不能依赖前端去控制).那么可能掉了两次领奖接口,而且有可能第二次调用的时候查询数据库的时候,第一次领奖还没有执行完成更新领奖标记. 这种场景就可以使用到synch

详解java中的synchronized关键字

Java语言的关键字,当它用来修饰一个方法或者一个代码块的时候,能够保证在同一时刻最多只有一个线程执行该段代码. 一.当两个并发线程访问同一个对象object中的这个synchronized(this)同步代码块时,一个时间内只能有一个线程得到执行.另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块. 二.然而,当一个线程访问object的一个synchronized(this)同步代码块时,另一个线程仍然可以访问该object中的非synchronized(this)同步代码块.

深入理解java中的synchronized关键字

synchronized 关键字,代表这个方法加锁,相当于不管哪一个线程A每次运行到这个方法时,都要检查有没有其它正在用这个方法的线程B(或者C D等),有的话要等正在使用这个方法的线程B(或者C D)运行完这个方法后再运行此线程A,没有的话,直接运行它包括两种用法:synchronized 方法和 synchronized 块. 1. synchronized 方法:通过在方法声明中加入 synchronized关键字来声明 synchronized 方法.如: 复制代码 代码如下: publ

Java中使用synchronized关键字实现简单同步操作示例

简单记录下java中synchronized关键字的使用方法. 在介绍之前需要明确下java中的每一个类的对象实例都有且只有一个锁(lock)和之相关联,synchronized关键字只作用于该锁,即可以认为synchronized只对java类的对象实例起作用. synchronized修饰函数 复制代码 代码如下: public synchronized aMethod(){ } 这就是最常用的情景,那么这个同步方法的用途是啥,为了方便就记作aMethod方法. 1.synchronized

实例解析JAVA中代码的加载顺序

Java中代码的加载顺序所能了解的知识点 类的依赖关系 static代码块的加载时间 继承类中构造器的隐式调用 非static的成员变量初始化时间 main方法和static的加载顺序 测试代码如下: public class App { private static App d = new App(); private SubClass t = new SubClass(); static{ System.out.println("App static");//6 } public

如何在JAVA中使用Synchronized

<编程思想之多线程与多进程(1)--以操作系统的角度述说线程与进程>一文详细讲述了线程.进程的关系及在操作系统中的表现,这是多线程学习必须了解的基础.本文将接着讲一下Java线程同步中的一个重要的概念synchronized. 在Java中,synchronized关键字是用来控制线程同步的,就是在多线程的环境下,控制synchronized代码段不被多个线程同时执行. synchronized是Java中的关键字,是一种同步锁.它修饰的对象有以下几种: 1. 修饰一个代码块,被修饰的代码块称

JAVA中的final关键字用法实例详解

本文实例讲述了JAVA中的final关键字用法.分享给大家供大家参考,具体如下: 根据上下文环境,java的关键字final也存在着细微的区别,但通常指的是"这是无法改变的."不想改变的理由有两种:一种是效率,另一种是设计.由于两个原因相差很远,所以关键子final可能被误用. 接下来介绍一下使用到final的三中情况:数据,方法,类 final数据 许多编程语言都有某种方法,来向编译器告知一块数据是恒定不变的.有时数据的恒定不变是很有用的,例如: 1. 一个编译时恒定不变的常量 2.

Java中的static关键字全面解析

static关键字是很多朋友在编写代码和阅读代码时碰到的比较难以理解的一个关键字,也是各大公司的面试官喜欢在面试时问到的知识点之一.下面就先讲述一下static关键字的用法和平常容易误解的地方,最后列举了一些面试笔试中常见的关于static的考题.以下是本文的目录大纲: 一.static关键字的用途 二.static关键字的误区 三.常见的笔试面试题 若有不正之处,希望谅解并欢迎批评指正. 请尊重作者劳动成果,转载请标明原文链接: http://www.cnblogs.com/dolphin05