深入探索Java特性中并发编程体系的原理和实战开发指南( 线程进阶技术专题)
- 前言介绍
- JVM内存模型
- 运行时数据区域
- 堆内存
- 栈内存
- 内存访问规则
- 原子性
- 对象类型
- 基本类型
- 可见性
- 有序性(Happen Before法则)
- 系统内存(MESI协议)
- 内存栅栏
- 读
- 写
- 查看JIT编译结果
- 这行配置的含义如下
- 缓存行对齐
- **缓存行对齐**
- **伪共享**
- 线程安全策略
- 不可变类
- 线程栈内使用
- 同步锁
- CAS (CompreAndSet)
- 编程建议指南
前言介绍
JVM内存模型是Java程序实现线程安全和并发性的重要基础,对于Java程序员来说必须深入理解其中的原理和细节,才能有效避免由JVM内部的并发问题导致的程序错误和性能问题。
JVM内存模型
JVM内存模型是Java虚拟机规范中定义的一种用于管理内存使用的模型,主要分为两个部分:
-
运行时数据区域:是Java程序运行时需要使用的内存区域,包括堆内存、栈内存、本地方法栈、方法区和程序计数器等。
-
内存访问规则:是Java程序中多线程访问共享数据时所需要遵守的规则,包括原子性、可见性和有序性。其中:
运行时数据区域
堆内存
在Java中,所有的对象实例的属性都存储在共享堆内存空间中。这个空间被单字节对齐,保证了内存的高效使用。需要注意的是,short类型的属性在堆内存中是无法被改变的。
栈内存
每个线程都有自己独立的线程栈空间,线程栈只存储基本数据类型和对象的地址。线程栈内存是4字节对齐且short型会被转化为int型。对象的地址长度为4字节且存储在引用堆空间中。方法内的局部变量存储在线程栈空间中,不会发生竞争,因此是线程安全的。方法的参数在栈顶交错存储,而不是被拷贝到栈顶寄存器中,这减少了中间状态的读取,同时也可以记录当前执行位置的PC指针。
内存访问规则
原子性
对象类型
-
对象地址的原子读写是线程安全的: 在Java中,对象地址的读写是原子性的,并且具有线程安全性。这意味着多个线程可以同时读取或写入对象的地址,而不会导致数据竞争或内存不一致问题。
-
对于不可变状态的并发读取是线程安全的: 如果对象在运行时保持其状态不变,那么多个线程同时读取它的状态是安全的。这个时候,读取操作之间没有任何干扰和依赖关系,并且可以自由地共享访问。因此,这种情况下是线程安全的。
-
对于可变状态的并发读写不是线程安全的: 如果一个对象在运行时可以被多个线程同时读取和写入,那么在并发访问的情况下,就会发生数据竞争和内存不一致问题。这时候需要采取线程同步的措施来保证线程安全。例如,可以使用synchronized或Lock等机制来确保同时只有一个线程在执行对对象的读取或写入操作。
基本类型
-
对于int和char类型的数值读写是线程安全的: 在Java中,int和char数据类型的读写操作是原子性的,因此具有线程安全性。这意味着多个线程可以同时读取或写入int和char类型的变量,而不会导致数据竞争或内存不一致的问题。
-
对于long和double类型的高低位读写并不是线程安全的: 在Java中,long和double数据类型的高低位读写是非原子性的,这意味着在多个线程同时对一个long或double进行读写时,可能导致数据竞争和内存不一致的问题。为了确保线程安全性,需要采用同步机制来解决这个问题。
-
i++等组合操作不是线程安全的: i++等组合操作包含读写两个操作,并且具有非原子性,因此在多线程并发执行时是非线程安全的。这意味着在多个线程对同一个变量执行i++等组合操作时,可能导致数据竞争和内存不一致的问题。为了确保线程安全性,需要采用同步机制来解决这个问题。例如,可以使用synchronized或AtomicInteger等线程安全类来进行同步控制。
可见性
下面是对你提供的内容的润色和优化:
-
final关键字可以确保final字段的可见性: 当一个final字段被初始化后,其值不能再被修改,这意味着在多个线程间访问final字段时不会出现内存不一致的问题。同时,在Java语言规范中,final字段的初始化具有内存屏障的作用,确保了final字段初始化后对其他线程的可见性。
-
volatile关键字可以确保volatile字段的可见性: 通过使用volatile关键字声明的变量,每次读写时都会对内存进行同步操作,确保了变量的可见性。这意味着当一个线程修改了volatile变量的值后,其他线程能够立即看到该变量新的值,而不会看到可见性问题引起的数据不一致。
-
synchronized关键字可以确保同步块内读写字段的可见性: 在一个synchronized块中,当一个线程对某个字段进行写操作时,会立即将其刷新到主存储器中,同时其他线程在进入该synchronized块时,会首先尝试从主存储器中获取最新的字段值,从而确保了字段的可见性和一致性。
-
happen-before规则可以确保遵守happen-before次序的可见性: happen-before规则是Java内存模型中的一组规则,可以确保多个线程间的内存可见性。其中,如果一个操作happen-before另一个操作,那么前一个操作的结果对于后一个操作来说是可见的。在Java中,例如synchronized同步块、volatile变量的读写、启动线程和join线程等操作都是基于happen-before规则的,可以确保多个线程之间的内存可见性。
有序性(Happen Before法则)
-
程序次序法则: 如果A发生在B之前,则A和B之间具有happen before关系。
-
监视器法则: 监视器的解锁一定发生在后续对同一监视器加锁之前。
-
Volatile变量法则: 写入volatile变量一定发生在后续对它的读取之前。
-
线程启动法则: 线程中的所有动作一定发生在Thread.start方法之前。
-
线程终结法则: 其他线程检测到某一线程已经终止,从Thread.join调用成功返回,或Thread.isAlive()返回false的发生一定在该线程中的所有动作之前。
-
中断法则: 一个线程调用另一个线程的interrupt方法一定发生在另一个线程检测到中断之前。
-
终结法则: 一个对象的构造函数结束一定发生在对象的finalizer方法之前。
-
传递性法则: 如果A发生在B之前,B发生在C之前,那么A一定发生在C之前。
系统内存(MESI协议)
Modified
本CPU写,则直接写到Cache,不产生总线事务;其它CPU写,则不涉及本CPU的Cache,其它CPU读,则本CPU需要把Cache line中的数据提供给它,而不是让它去读内存。
Exclusive
只有本CPU有该内存的Cache,而且和内存一致。 本CPU的写操作会导致转到Modified状态。
Shared
多个CPU都对该内存有Cache,而且内容一致。任何一个CPU写自己的这个Cache都必须通知其它的CPU。
Invalid
一旦Cache line进入这个状态,CPU读数据就必须发出总线事务,从内存读。
内存栅栏
读
volatile int a, b; if(a == 1 && b == 2)
JIT通过load acquire依赖保证读顺序:
0x2000000001de819c: adds r37=597,r36;; ;...84112554
0x2000000001de81a0: ld1.acq r38=[r37];; ;...0b30014a a010
写
volatile A a; a = new A();
JIT通过lock addl使CPU的cache line失效:
0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: lock addl $0x0,(%esp);
查看JIT编译结果
java -XX:+UnlockDiagnosticVMOptions -XX:PrintAssemblyOptions=hsdis-print-bytes -XX:CompileCommand=print,*AtomicInteger.incrementAndGet
这行配置的含义如下
java
:代表要启动 Java 虚拟机以执行 Java 代码。-XX:+UnlockDiagnosticVMOptions
:打开 JVM 的诊断选项,该选项允许开发人员使用需要特权的命令。-XX:PrintAssemblyOptions=hsdis-print-bytes
:使用 HSDis(HotSpot Disassembler)打印本机代码的字节表示。这可以用于调试,分析代码优化和性能问题。-XX:CompileCommand=print,*AtomicInteger.incrementAndGet
:当使用 JIT 编译器编译调用AtomicInteger.incrementAndGet()
方法的代码时,打印代码的汇编输出。
综上所述,这条配置命令允许开发人员在 JVM 中启用诊断选项并使用 HotSpot Disassembler 打印 Java 代码编译成的机器码汇编输出。其中,通过 -XX:CompileCommand=print,*AtomicInteger.incrementAndGet
捕捉了 AtomicInteger.incrementAndGet()
这个方法的编译过程,可以分析该方法对应的本地代码的汇编输出,这对于调试和分析性能问题非常有用。
缓存行对齐
缓存行对齐和伪共享都是与CPU缓存有关的概念。缓存是小型且从主内存中读取和写入数据比内存操作更快的内存。缓存行大小通常是64字节。当多个线程或处理器核心在操作共享变量时,缓存就会成为一个问题。
缓存行对齐
每个缓存行保存着多个数据元素。如果两个数据元素在同一个缓存行中,它们会在同一时刻被加载到CPU缓存中,这样就可以提高程序的性能。因此,缓存行对齐是在我们能够控制的数据元素之间添加填充以使它们位于不同的缓存行中。对于Java,可以使用 sun.misc.Contended
注解来实现线程对齐。
LinkedTransferQueue
static final class PaddedAtomicReference <T> extends AtomicReference <T> {
Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
PaddedAtomicReference(T r) {
super(r);
}
}
16个地址的长度,刚好占满一个cache line的长度。确保两个引用,不在同一cache line上,防止多锁竞争。
伪共享
当两个线程完全独立但共享同一个缓存行中的不同数据元素时,可能会发生伪共享。操作其中一个数据元素会导致整个缓存行从内存中加载到CPU缓存,这会导致另一个线程无法访问该缓存行。这种现象称为“伪共享”,它会导致性能下降。
为了解决伪共享问题,可以使用一些技术来提高线程之间的独立性,例如将共享的数据分离到单独的缓存行中或者使用volatile变量或者Atomic方式。此外,可以使用一些特殊的注解来告诉编译器和运行时环境我们希望它们处理伪共享,例如Java 8开始引入的 sun.misc.Contended
注解。
线程安全策略
不可变类
如果一个类初始化后,所有属性和类都是final不可变的,这确实可以增加线程安全性,可以避免一些并发问题,例如多个线程同时修改同一对象可能出现的数据不一致等。
然而,即使这个类没有显式的同步,也不能保证它的线程安全,因为final关键字只能保证对对象的引用不变,而不能保证对象本身的线程安全性。如果这个类的方法没有进行同步,需要访问的对象的状态可能会发生变化,从而导致并发问题,例如读取脏数据、重复数据、遗漏数据等。
因此,即使一个类所有属性和类都是final不可变的,也不能保证这个类的线程安全,需要视具体情况进行合适的同步或其他线程安全措施。只有在确保访问对象的方法也是线程安全的情况下,才能认为这个类是完全线程安全的。
线程栈内使用
涉及多线程应用程序时,有几种方法可以提高应用程序的性能和可维护性,分别是方法内局部变量使用、线程内参数传递和使用ThreadLocal持有变量。
- 方法内局部变量使用
对于方法中仅在方法内被使用的变量,应该将其声明为局部变量,而不是作为全局变量存储在堆上。这样可以减少对象的创建数量,从而降低垃圾回收的负载,提高代码执行效率。
- 线程内参数传递
在多线程应用程序中,当一个方法需要访问某个对象时,最好将对象引用传递到方法中,而不是将对象作为全局变量。这样可以避免多个线程同时修改同一个对象的情况,从而提高应用程序的线程安全性。
- ThreadLocal持有变量
在线程之间传递数据时,如果不希望将数据暴露给其他线程,可以将数据存储在ThreadLocal对象中。ThreadLocal是Java中的一种线程范围内的数据结构,可以用于在不同的线程中存储和获取对象的值,而不必担心多个线程之间干扰。
综上所述,方法内局部变量的使用、线程内参数传递和使用ThreadLocal都是优化多线程应用程序的有效方法。在编写代码时,应综合考虑以上因素,以提高代码的执行效率和可维护性。
同步锁
- 使用synchronized关键字锁定的代码会保证在同一时刻只有一个线程可以访问共享资源,因此它具有较高的线程安全性。但是,由于每个线程都必须等待前一个线程完成它的工作后才能继续执行,因此活性较低。
- volatile变量可以确保可见性和禁止重排序,但只能在一些有限的情况下使用。可以使用锁外双重检测来尽可能地减少锁竞争,提高程序的性能。需要注意的是,对于访问次数较少的变量,使用volatile变量作为同步锁可能会影响程序的性能,因为锁的开销相对较大。
- 读写条件分离、锁粒度分级和排序锁等技术可以降低锁的竞争,提高程序的性能。读写条件分离指的是将对共享资源的读和写操作分别加锁,从而允许多个线程并发地进行读操作。锁粒度分级指的是根据数据结构的特点,将锁的粒度分为不同的级别,避免过度细粒度的锁导致的锁竞争。排序锁则是对锁进行排序,以避免死锁和饥饿问题。这些技术需要根据具体情况进行灵活应用,以达到最优的性能和线程安全性的平衡。
CAS (CompreAndSet)
这里描述了一种基于“冲突检测与重试”的乐观并发方案。在这种方案中,每次更新操作时,使用比较并交换(CAS)指令判断当前值是否与期望值相等,若相等,则更新为新值;否则,表示中途有其他线程修改过该值,需要重新读取值并重试操作。
这种方案的优点是在没有竞争的情况下,可以快速地进行操作,提高程序的性能,并且不会发生死锁和饥饿问题。缺点是在竞争情况下,需要频繁地进行重试操作,消耗较多的CPU资源,并且可能导致进程的长时间阻塞,因此需要根据具体应用情况进行评估。
需要注意的是,乐观并发方案适用于不需要特别强的一致性要求,且数据冲突发生的概率较低的场景,例如计数器等任务。如果数据冲突较为频繁,建议采用悲观并发方案(例如使用锁进行同步),以保证数据的安全性和一致性。
编程建议指南
在编写代码时,需要考虑以下问题:
-
敲每个点号时,是否会出现空指针异常?
-
是否会有异常抛出?
-
代码是否在热点区域?
-
代码是在哪个线程执行?
-
是否存在并发锁的间隙?
-
是否会并发修改不可见?