Java内存模型
并发编程的两个关键问题:线程之间如何通信及线程之间如何同步
1、线程之间的通信机制有两种:共享内存和消息传递
在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来显式进行通信。
2、同步是指程序中用于控制不同线程间操作发生相对顺序的机制。
在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。
在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。
Java并发采用的是共享内存模型,Java线程之间的通信总是隐式进行的,整个通信过程对程序员完全透明。
Java内存模型的抽象结构
在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数和异常处理参数不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
Java线程之间的通信由Java内存模型(JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在,它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。
从源代码到指令序列的重排序
重排序的三种类型:
(1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
(2)指令级并行的重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序来将多条指令重叠执行。
(3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
这些重排序可能会导致多线程程序出现内存可见性问题。
JMM的编译器重排序规则会禁止特定类型的编译器重排序(非所有);JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
final域的内存语义
final域的重排序原则
1)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
2)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
写final域的重排序规则
写final域的重排序规则禁止把final域的写重排序到构造函数之外。
1)JMM禁止编译器把final域的写重排序到构造函数之外。
2)编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外。
写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。
读final域的重排序规则
在一个线程中,初次读对象引用与初次读该对象包含的final域,JMM禁止重排序这两个操作(这个规则仅仅针对处理器)。编译器会在读final域操作的前面插入一个LoadLoad屏障。
读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。
final域为引用类型
对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
happens-before
JMM的设计
JMM的重排序策略:
(1)对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
(2)对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种重排序)。
happens-before的定义
(1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见。
(2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按着happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(JMM允许这种重排序)。
happens-before关系本质上和as-if-serial语义是一回事
1)as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同步的多线程程序的执行结果不被改变。
2)as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序来执行的。
as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提下,尽可能地提高程序执行的并行度。
happens-before规则
(1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
(2)监视器锁原则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
(3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
(4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
(5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
(6)join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
双重检查锁定与延迟初始化
在Java多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。
双重检查锁定(错误用法)
基于volatile的解决方案
基于类初始化的解决方案
JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。
在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化。
(1)T是一个类,而且一个T类型的实例被创建。
(2)T是一个类,且T中声明的一个静态方法被调用。
(3)T中声明的一个静态字段被赋值。
(4)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
(5)T是一个顶级类,而且一个断言语句嵌套在T内部被执行。
在InstanceFactory实例代码中,首次执行getInstance()方法的线程将导致InstanceHolder类被初始化(符合情况4)。
类初始化过程的5个阶段:
第一阶段:通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。
第二阶段:线程A执行类的初始化,同时线程B在初始化锁对应的condition上等待。
第三阶段:线程A设置state = initialized,然后唤醒在condition中等待的所有线程。
第四阶段:线程B结束类的初始化处理
第五阶段:线程C执行类的初始化的处理
这里的state和condition标记是虚构出来的。JVM的具体实现只要实现类似功能即可。
两种解决方案的对比
基于类初始化的方案的实现代码更简洁。基于volatile的双重检查锁定的方案有一个额外的优势:除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。
字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段的开销。在大多数时候,正常的初始化要优于延迟的初始化。如果确实需要对实例字段使用线程安全的延迟初始化,请使用基于volatile的延迟初始化方案;如果确实需要对静态字段使用线程安全的延迟初始化,请使用基于类初始化的方案。