Java内存模型与多线程

一、Java内存模型

Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问的差异,实现让Java程序在各种平台下都能达到一致的内存访问效果。(JDK1.5以后Java内存模型已经成熟和完善起来了)

1.1、主内存与工作内存

Java内存模型规定了所有的变量存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory)线程工作内,线程的工作线程内保存了线程内使用的变量的的主内存副本拷贝,线程内对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也不能直接访问别的线程工作内存中的变量,线程间传递变量都需要通过主内存来完成。
Java内存模型与多线程

1.2、内存间相互交互操作

Java主内存与工作内存之间具体的交互协议(一个变量如何从主内存拷贝到工作内存,如何从工作内存同步回主内存之类的实现细节)

1.2.1 Java内存模型8种操作定义

Java内存模型中定义了8中操作来完成,虚拟机保证这几种操作都是原子的、不可再分的。

  • lock(锁定): 作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁): 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取): 作用于主内存的变量,它把一个变量的值从主内存传值到线程的工作内存中,以便于随后的load动作使用。
  • load(载入): 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用): 作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的字节码指令时将会执行这个操作。
  • assign(赋值): 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储): 作用于工作内存的变量,它把工作内存中的一个变量的值传送到主内存中,以便于write操作使用。
  • write(写入): 作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

如果要把一个变量从主内存复制到工作内存中,就要按照顺序执行readload,如果要把工作内存同步回主内存,就要顺序执行storewrite
注意:Java内存模型只要求上述两个操作必须是顺序执行的,而没有保证是连续执行的,readload之间,storewrite之间可以插入其他指令,如果对主内存中的变量a、b进行访问时,可能出现的顺序是read a、read b、load b、load a

1.2.2 Java内存模型8种操作执行规则
  • 不允许readloadstorewrite操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接收,或工作内存发起回写但是主内存不接收。
  • 不允许一个线程丢弃他最近的assign操作,即变量在工作内存中改变了之后必须把变化同步回主内存。
  • 不允许一个线程无原因的(没有发生任何assign操作)把数据从线程的工作内存同步回主内存中
  • 一个新的变量只能在主内存中初始化,不允许在工作内存中直接使用一个未被初始化(loadassign)的变量,意思就是,在usestore之前必须是执行了assign和load操作。
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock以后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果一个变量事先没有被lock锁定,那就不允许对他使用unlock操作,也不允许去unlock一个被其他线程锁定的变量。
  • 如果一个变量执行lock操作,那就会清空工作内存中的此变量的值,在执行引擎使用这个变量前,需要重新执行loadassign操作初始化变量的值。
  • 对一个变量执行unlock操作之前,必须把此变量同步回主内存(执行storewrite操作)

1.3 指令重排序

普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这就是Java内存模型中描述的所谓的“线程内变现为串行的语义(Within-Thread As-If-Serial Semantics)
从硬件架构上来讲,指令重排序是指CPU采用允许将多条指令不按程序规定的顺序分开发送给相应电路单元处理。但并不能说指令任意的重排序,CPU需要能正确的处理指令依赖情况来保证程序能得出正确的结果。譬如指令1把地址A的值加10,指令2把地址A中的值乘以2,指令3把地址B中的值减3,这是指令1和指令2是有依赖的,他们之间的顺序不能重拍,但是指令3可以排到指令1和指令2中间,只要保证CPU执行后面依赖到A、B值的操作是能得到正确的值即可。

1.4 volatile

关键字volatile是Java虚拟机提供的轻量同步机制,但是并不容易完全被正确的、完整的理解,以至于很多人直接会用synchronized关键字来进行同步。但volatile并不能保证绝对的线程安全。
volatile第一个语义就是能保证变量在线程间的可见性
volatile第二个语义就是禁止指令重排序优化
volatile的使用场景

  • 运算结果并不依赖变量的当前值,或者能保证只有单一线程修改变量的值
  • 变量不需要与其他的状态变量共同参与不变的约束

除此之外的其他场景仍要通过加锁来解决。

1.5 原子性、可见性、有序性

原子性(Atomicity): 由Java内存模型来直接保证原子性的变量操作包括read、load、assign、use、store、write,所以我们可以大致认为基本数据类型的读写是原具备子性的。
如果需要大范围的原子性保证,Java内存模型提供了lockunlock操作来满足这个需求。虽然虚拟机未把lockunlock开放给开发者使用,但是却提供了令两个字节码指令monitorentermonitorexit来隐式的使用者两个操作。这两个字节码对应到Java代码中就是同步块synchronized关键字,因此synchronized代码块间的操作也具备原子性。
synchronized编译后就是monitorentermonitorexit包围的代码块。
可见性(Visibility): 可见性是指当一个线程中修改了共享变量的值,其他线程能够立即得到这个值修改。Java内存模型中是通过在工作内存中修改变量的值以后同步回主内存中,在变量读取前重新在主内存刷新变量的新值使用来实现可见性,普通变量和volatile关键字修饰的变量同样是如此。但是普通变量与volatile关键字修饰的变量的区别是,volatile变量保证了新值能立刻同步回主内存,并且,每次使用前都去主内存刷新。因此说volatile保证了多线程的变量可见性,但一般变量不能保证这一点。
除了volatile以外,Java还有两个关键字可以实现可见性,synchronizedfinal
有序性(Ordering): Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句指“线程内表现为串行语义”,后半句指的是“指令重排序”现象和“工作内存与主内存同步延迟”现象,Java提供了volatilesynchronized两个关键字来保证线程之间操作的有序性。

1.6 先行发生原则

Java内存模型有一些“天然的”先行发生关系,这些关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作不在刺裂,并无法从下列推导出来的话,他们就没有顺序保证,虚拟机可以对它们随意的进行重排序

  • 程序次序规则: 在一个线程内,按照程序的代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确的说,应该是控制流顺序而不是程序代码的顺序,因为考虑分支、循环等结构。
  • 管理锁定顺序: 一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调是同一个锁,而后面是指的时间上的先后顺序。
  • volatile变量规则: 对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的后面同样是时间的先后。
  • 线程启动规则: Threadstart()方法先行与这个线程执行的每一个动作。
  • 线程终止规则: Thread的所有操作都先与线程的终止。我们可以通过Thread.join()方法结束、Thread.isAlive()的返回来检测线程已经终止。
  • 线程中断规则:interrupt()方法的调用先行发生于被中断时间的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  • 对象终结规则: 一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
  • 传递性: 如果操作A先发与操作B,而操作B先发与操作C,那么操作A则先发与操作C。

二、Java多线程

2.1 状态转换

Java定义了5种状态,在任意一个时间点,一个线程只能有且只能有其中的一种状态

  • 新建(New): 创建后尚未启动的线程,比如 new Thread();
  • 运行(Runable): Runable包括了操作系统线程状态中的RunableReady,也就是处于此状态的线程可能正在执行,也有可能在等待CPU为他分配执行时间。
  • 无限期等待(Waiting): 处于这种状态的线程不会被CPU分配执行时间,它要等待被其他线程显式的唤醒。会让线程进入无限期等待状态的方法:
  1. 没有设置timeoutObject.wait()方法。
  2. 没有设置timeoutThread.join()方法。
  3. LockSupport.part()方法。
  • 限期等待(Timed Waiting): 处于这种状态的线程也不会被分配CPU执行时间,但是无须其他线程唤醒,一定时间以后会自动唤醒,会让线程进入限期等待的方法:
  1. Thread.sleep();
  2. 设置timeoutObject.wait()方法;
  3. 设置了timeout参数的Thread.join()方法;
  4. LockSupport.partNanos()方法;
  5. LockSupport.partUnit()方法;
  • 阻塞(Blocked): 线程被阻塞了,“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待着获取到一个排它锁,这个事件将在另一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或唤醒动作的发生。程序在等待进入同步块的时候线程将进入这种状态。
  • 结束(Terminated): 已经终止的线程状态。
    Java内存模型与多线程

2.2 线程安全

当多个线程访问一个对象时,如果不用考虑这些线程在运行环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。

2.3 Java中的线程安全

  • 不可变: 不可变的对象一定是线程安全的
  • 绝对的线程安全:不管任何运行环境,调用者不需要任何额外的同步措施
    这个“绝对”是什么意思呢,比如说大家都知道util包有很多线程安全的容器,比如java.util.Vector这个容器。它安全的原因在于它在被调用的方法上都加上了synchronized关键字,这就是理论上的线程安全,但是在多线程环境下,容器的遍历却不是线程安全的,如果一个线程恰好在错误的时间删除一个元素,那另一个线程便利的时候就会发生ArrayIndexOutOfBoudsException异常。
  • 相对线程安全: 上面说的Vector、HashTable、CollectionssynchronizedCollection() 方法包装的集合等。
  • 线程兼容: 对象本身不是线程安全的,但是可以通过调用端正确的使用同步手段来保证在并发环境中可以安全使用。比如ArrayListHashMap等。
  • 线程对立: 无论是否采用了同步措施,都无法在多线程环境中并发使用的代码。

2.4 线程安全的实现方法

  • 互斥同步: 使用锁或者同步块,悲观的并发策略。
    synchronizedjava.util.concurrent.ReentrantLock(可重入锁)来实现。区别在于一个是JavaAPI层面实现(lock()、unlock()方法配合try/finally 语句来实现),另一个表现为原生语法层皮的锁。ReentrantLock多了一些高级功能:等待可中断、公平锁、锁可以绑定多个条件。
    synchronizedReentrantLockJDK1.6以后性能基本持平了,所以选择用哪种的话看需求就可以了。
  • 非阻塞同步: 基于冲突检查的乐观并发策略。
    比如java.util.concurrent.AtomicInteger就是采用了CAS(Compare-and-Swap)比较并交换。
    CAS指令需要有3个操作值,变量的内存地址V,旧的预期值A,新值B,当执行CAS指令时,当且仅当V符合旧值A的时候才把新值B更新上去,否则就不更新。CAS指令是一个原子指令,可以一步完成上面所说的操作。Java程序中由sun.misc.Unsafe类的compareAndSwap()提供。
    虽然CAS看起来很好使,但是也不能覆盖所有场景,CAS会遇到ABA问题,就是第一次获取值的时候获取到的是A那么并不代表初始化的时候就是A,可能初始化是B可是二次赋值成了A导致获取到的是A,这就是所谓的ABA问题。
  • 无同步方案: 要保证线程安全不一定要进行同步操作,只要调用合理,或者这个数据本就不是共享数据,那么它就是天然的线程安全。