深入理解JVM(十一)——Java内存模型与线程

 2019-12-10 16:09  阅读(771)
文章分类:Java Core

计算机运算的速度,与它的存储和通讯子系统相差太大,大量的时间花费在磁盘IO,网络通讯和数据库上。

衡量一个服务性能的高低好坏,每秒事务处理数TPS是最重要的指标。

对于计算量相同的任务,程序线程并发协调的越有条不紊,效率越高;反之,线程之间频繁阻塞或是死锁,将大大降低并发能力。

硬件的效率与一致性

绝大多数的运算任务不能只靠处理器计算就能完成的,至少要与内存交互,如读取运行数据,存储运算结果等,由于计算机的存储设备与计算机的运行有几个数量级的差别,现在计算机系统不得不加入一种高速缓存cache用作内存和处理器之前的缓冲。将运算的数据复制到缓存中,让运行能快速进行,将计算后的结果从缓存会写到内存中。

基于高速缓存的存储交互解决了处理器与内存的速度矛盾,但是引入了新的问题:缓存一致性

在多处理器系统中,每个缓存都有自己的高速处理器,他们又共享同一主内存。当多处理器的运算任务涉及同一主内存区域时,就可能导致缓存数据不一致。

除了增加缓存外,为了尽量利用处理器的运算单元,处理器会对输入代码进行乱序执行(Out Of Order Execution)优化,处理器会在计算后将乱序执行的结果重组,保证该结果与顺序执行的结果一致,但并不保证各个语句计算的先后顺序与输入代码的顺序一致。
与之相同JVM也有类似的指令重排优化

主内存和工作内存

Java内存模型主要是定义程序中各个变量的访问规则,即在虚拟机中变量存储到内存和从内存中取出变量 的底层细节。
此处变量与Java编程中的变量有区别,包括实例字段,静态字段和构成数组对象的元素,不包括局部变量和方法参数,因为后者为线程私有不会被共享。

Java内存模型规定所有的变量都存储在主内存(可与物理机主内存对比)中,每个线程还有自己的工作内存(可与高速缓存对比),线程的工作内存中保存了被该线程使用到的变量的主内存副本的拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

内存间交互操作

对于主内存和工作内存之间的交互协议,及一个变量如何从主内存拷贝到工作内存,从工作内存同步回主内存的实现细节,Java定义了8中操作来完成,虚拟机保证每一种都是原子的,不可再分的(double,long类型的load,store,read,write某些平台运行有例外)。

lock——锁定,作用于主内存变量,把一个变量标志为线程独占状态
unlock——解锁
read——读取,将一个变量从主内存读到工作内存
load——载入,把read的结果放入工作内存的变量副本中
use——使用,将工作内存的值传递给执行引擎
assign——赋值,将执行引擎接受到的值赋给工作内存的变量
store——存储,将工作内存的变量传递给主内存
write——写入,把store的值放在主内存变量中

把一个变量从主内存赋值到工作内存,要顺序执行read和load操作;把变量从工作内存同步到主内存,要顺序执行store和Write操作。Java只要求顺序执行,但是不保证连续执行。

Java还规定了8中基本操作时必须满足的规则

  1. 不允许read和load,store和Write操作之一单独出现
  2. 不允许一个线程丢弃它最近的assign操作,即变量在工作内存改变后必须同步回主内存
  3. 不允许线程无原因(未发生assign操作)把数据同步回主内存
  4. 一个新的变量只能从主内存诞生
  5. 一个变量同一时刻只允许一条线程对其lock,但是可以重复执行多次,对应的要执行多次unlock才会解锁
  6. 如果对一个变量执行lock,那么就会清空工作内存的值,执行引擎使用前,需要重新执行load或者assign操作
  7. 如果一个变量没有变lock,则不允许对它执行unlock;也不运行去unlock被其它线程lock的变量
  8. 对变量unlock之前,必须把变量同步回主内存

volatile型变量的特殊规则

关键字volatile是Java提供的最轻量级的同步机制

变量定义为volatile后具备两种特性

  1. 保证此变量对所有线程的可见性,一条线程修改了值,新值对其它线程来说都是可以立即知道的。
  2. 禁止指令重排序优化。

原子性,可见性与有序性

  • 原子性——Atomicity
    Java内存模型保证原子性的操作有read,load,assign,use,store和Write,大致可以认为基本数据类型的访问读写是原子的。
  • 可见性
    当一个线程修改了共享变量时,其它线程能立即知道这个修改
  • 有序性
    如果在本线程内观察所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。

先行发生原则 happens-before

先行关系是Java内存模型定义的两项操作之间的偏序关系,如果说操作A先行发生与操作B,其实就是说发生操作B之前,操作A产生的影响能被操作B观察到,影响包括修改了内存中共享变量的值,发送了消息,调用了方法等。

  1. 程序次序规则,一个线程内按照程序代码执行,指的是控制流顺序
  2. 管程锁定规则,一个unlock操作先行发生于后面对同一个锁的lock操作,同一个锁,时间上的先后顺序
  3. volatile变量规则,对一个volatile变量的写操作先行发生与后面对这个变量的读操作,时间上的先后顺序
  4. 线程启动规则,线程的start()方法先行发生于线程的每一个动作
  5. 线程终止规则,线程中的所有操作都先行发生于线程的终止检测。
  6. 线程中断规则,对线程的interrupt()方法的调用优先发生于被中断线程的代码检测到中断事件的发生。
  7. 对象终结规则,一个对象初始化完成先发生于它的finalize()方法的开始
  8. 传递性,操作A先于操作B,操作B先与操作C,则操作A先与操作C

线程安全

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

不可变

Java语言中不可变的对象一定是线程安全的

绝对线程安全

不管运行时环境如何,调用者都不需要任何额外的同步措施。通常付出的代价很大。

相对线程安全

需要保证这个对象单独的操作是线程安全的,我们在调用的时候无须做额外的保障措施,但对于一些特定顺序的连续调用,可能需要额外的同步手段保障正确性。

线程兼容

是指对象本身并不是线程安全的,但是可以通过在调用端使用正确地同步手段保证对象在并发环境中安全使用

线程对立

无论调用端是否采取同步措施,都无法在多线程环境中并发使用的代码(线程的suspend和resume方法,死锁的风险)

线程安全的实现方法

  • 互斥同步
    同步是指多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个或者一些(使用信号量的时候)线程使用。互斥是实现同步的手段,临界区,互斥量,信号量都是主要的互斥实现方法。
    Java中最基本的互斥手段是synchronized关键字,编译后会在同步快前后形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。
    另外concurrent包中提供的可重入锁ReentrantLock也可以实现互斥,另外提供高级的等待可中断,可实现公平锁,锁绑定多个条件等功能
  • 非阻塞同步
    互斥主要是进行线程阻塞和唤醒的操作,也称阻塞同步,是一种悲观的并发策略。
    非阻塞同步是一种乐观的并发策略,基于冲突检测,就是先进行操作,如果没有其它线程争用共享数据,那操作就成功;如果共享数据有争用,产生冲突,则采取其它的补偿。这种方式不需要把线程挂起,因此称为非阻塞。
    乐观锁需要操作和冲突检测这两个步骤具备原子性,也就是需要硬件指令集的支持,这类指令集常有
  • 测试并设置(test and set)
  • 获取并增加(fetch and increment)
  • 交互(swap)
  • 比较并交换(compare and swap,CAS)
  • 加载链接/条件存储(load linked/store conditional)

CAS需要3个操作数,内存位置(V),旧的预期值(A),新值(B)。有且仅当V符合旧预期值A时,用B更新V的值,否者它就不执行更新,但是无论是否更新了V都会返回V的旧值,上述处理过程是一个原子操作。
ABA问题,通过版本解决,大部分不影响可以不解决;一定要解决可以通过互斥。

锁优化

HotSpot团队一直致力于各种锁优化的技术,如适应性自旋,锁消除,锁粗化,轻量级锁和偏向锁等。

  • 自旋锁和自适应锁
    上面说到互斥最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转人内核态去完成,这个操作给系统并发带来很大的压力。
    共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起线程和恢复线程是不值得的,。如果物理机可以让线程并行执行,可以让后面请求锁的进程稍等,但不要放弃处理器的执行时间,为了让线程等待需要线程执行一个忙循环(自旋)。
    如果锁被占用的时间很短,自旋等待的效果非常好;如果锁占用的时间长,则自旋只会白白消耗处理器资源。
    自适应自旋是只自旋的时间不在固定(JVM参数),而是由前一次同一个锁上的自旋时间及锁持有者的状态来决定的。
  • 锁消除
    逃逸分析,变量如果没有逃逸出线程,则共享数据的竞争锁可以消除。(也许程序员显示没有加锁但是JVM优化,比较s1+s2+s3,变成stringBuffer.append()方法)
  • 锁粗化
    原则上同步块的作用范围尽量小,只在共享数据的实际作用域中进行同步。
    如果一系列的连续操作要对同一对象反复加锁和解锁,甚至加锁操作出现在循环体中,就会导致不必要的性能消耗。
  • 轻量级锁
    轻量级指的是相对操作系统互斥量来实现的传统锁而言的。轻量级锁并没有要代替重量级的锁,也没有在多线程的情况下减少传统的重量级锁的性能消耗意思。
    HotSpot虚拟机的对象头分为两个部分,第一个部分用于存储对象自身的运行数据,,如哈希码,GC分代年龄等。这部分是实现轻量级锁和偏向锁的关键。
    32位虚拟机中,对象未被锁定的状态下,Mark Word的32Bit用于存放25Bit的哈希码,4Bit的GC分代年龄,2Bit的存储锁标志位,1Bit固定为0;在其它状态下(轻量级锁定,重量级锁定,GC标志,可偏向)对象的存储内容是
存储内容 标志位 状态
哈希码和对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重量级锁的指针 10 重量级锁定
空,不需要记录信息 11 GC标志
偏向线程ID,偏向时间戳,对象分代年龄 01 可偏向

代码进入同步块的时候,如果同步对象没有别锁定(锁标志位01状态),虚拟机首先在当前线程的栈帧中建立一个锁记录的空间,存放锁对象目前的Mark Word拷贝,然后虚拟机采用CAS操作尝试将对象的Mark Word更新为指向锁记录的指针。如果操作成功,这个线程拥有该对象的锁,并且对象的锁标志位转变成00。
如果这个更新失败,虚拟机会检查对象的Mark Word是否指向当前线程的栈帧,如果只说明当前线程已经拥有这个对象的锁,就可以直接进入同步块继续执行,否则说明这个锁对象被其它线程抢占了。
如果两条以上线程争用同一个锁,则轻量级锁不在有效,膨胀为重量级锁。锁标志位变为10,Mark Word中存储的是指向重量级锁(互斥量)的指针。后面等待锁的线程也进入阻塞状态。
解锁的过程也是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS去替换。如果替换成功则同步过程完成,如果失败,则说明其它线程尝试过获取该锁,要在释放锁的同时,唤醒被挂起的线程。

  • 轻量级锁
    消除数据在无竞争情况下的同步原语,进一步提供程序的运行性能。如果说轻量级锁实在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS也不做。
    锁会偏向与第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其它线程获取,则持有偏向锁的线程将永远不需要再进行同步。
点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 深入理解JVM(十一)——Java内存模型与线程

相关推荐