java内存模型、synchronized

113 阅读13分钟

java内存模型(Java Memory Model)

内存模型可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写的过程抽象。

主内存与工作内存

java内存模型 规定所有变量都存储在主内存(main memory)中,每条线程都有自己的工作内存(working memory),工作内存中保存了该线程使用变量的主内存副本,线程对变量的读写操作都在工作内存中完成,不能直接读写主内存中的数据。不同工作内存无法互相访问,线程间的变量传递均需通过主内存完成。

工作内存与主内存的交互操作,虚拟机保证都是原子进行

  1. lock锁定:作用于主内存,标记一个变量被一条线程独占(lock操作执行后,将清空工作内存中变量的值,在执行引擎使用这个变量前,需要重新load或assign以初始化变量的值)
  2. unlock解锁:作用于主内存,标记一个变量被一条线程释放,释放后的变量才能被其他线程锁定(unlock前必须将此变量同步回主内内存,也就是先store、write)
  3. read读取:作用于主内存,将一个变量的值从主内存传输到线程的工作内存,供后续load使用
  4. load载入:作用于工作内存,将read来的变量值放入工作内存的副本中
  5. use使用:作用于工作内存,将工作内存的变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量值的字节码指令时执行这个操作
  6. assign赋值:作用于工作内存,把从执行引擎接收的值赋值给工作内存的变量,每当虚拟机遇到一个需要给变量赋值的字节码指令时执行这个操作
  7. store存储:作用于工作内存,将工作内存的变量值传给主内存,共后续write使用
  8. write写入:作用于主内存,把store来的变量值放入主内存的变量中

read 和 load,store 和 write必须顺序执行,但不要求连续。

volatile型变量

保证可见性,有序性,无法保证原子性

当一个变量被定义成volatile后,其具备两项特性

  1. 保证此变量对所有线程的可见性。(一条线修改了改变量的值,新值对其他线程立即可知。普通变量需要等到该线程向主内存回写后,另一条线程再对主内存读取才能知道。)volatile变量在各个线程的工作内存中不存在不一致行问题(物理意义存在不一致,但每次使用前都要先刷新,执行引擎看不到不一致的情况,所以可以认为不存在一致性问题),但java的运算操作符并非原子操作,所以volatile变量的运算在并发下并非安全(当volatile变量为布尔类型且不需要做运算时是适合来做并发控制的)。

    基于mesi缓存一致性,使修改后的值立即写回主存,(lock-store-write-unlock),同时因为cpu总线嗅探机制,每个处理器通过嗅探在总线传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态, 重新从系统内存中把数据读到处理器缓存里

  2. 禁止指令重排序优化

    汇编lock前缀指令

    1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

    2)它会强制将对缓存的修改操作立即写入主存,同时使其他所有工作内存中变量的值失效(现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,较少对内存总线的占用。但是什么时候写入到内存是不知道的。强制刷新写缓存,将使得当前线程写入 volatile 字段的值(以及写缓存中已有的其他内存修改),同步至主内存之中)

volatile 字段的另一个特性是即时编译器无法将其分配到寄存器里。换句话说,volatile 字段的每次访问均需要直接从内存中读写。所谓的分配到寄存器中,可以理解为编译器将内存中的值缓存在寄存器中,之后一直用访问寄存器来代表对这个内存的访问的。假设我们要遍历一个数组,数组的长度是内存中的值。由于我们每次循环都要比较一次,因此编译器决定把它放在寄存器中,免得每次比较都要读一次内存。对于会更改的内存值,编译器也可以先缓存至寄存器,最后更新回内存即可

先行发生原则 happends-before

happens-before 关系是用来描述两个操作的内存可见性的。如果操作 X happens-before 操作 Y,那么 X 的结果对于 Y 可见。同一个线程内,如果后者没有观测前者的运行结果,即后者没有数据依赖于前者,那么它们可能会被重排序。

int i = 1;
int j = 2;
这两个操作就可能发生重排

线程间的 happens-before 关系。

  1. 解锁操作 happens-before 之后(这里指时钟顺序先后)对同一把锁的加锁操作。
  2. volatile 字段的写操作 happens-before 之后(这里指时钟顺序先后)对同一字段的读操作。
  3. 线程的启动操作(即 Thread.starts()) happens-before 该线程的第一个操作。 线程的最后一个操作 happens-before 它的终止事件(即其他线程通过 Thread.isAlive() 或 Thread.join() 判断该线程是否中止)。
  4. 线程对其他线程的中断操作 happens-before 被中断线程所收到的中断事件(即被中断线程的 InterruptedException 异常,或者第三个线程针对被中断线程的 Thread.interrupted 或者 Thread.isInterrupted 调用)。
  5. 构造器中的最后一个操作 happens-before 析构器 (finalize()方法)的第一个操作。

java语言无需任何同步手段即可保障上述情况的先行发生规则,除此外就需根据需要考虑并发安全问题。

synchronized

代码块

当声明 synchronized 代码块时,编译而成的字节码将包含 monitorenter 和 monitorexit 指令。 字节码中包含一个 monitorenter 指令以及多个 monitorexit 指令。这是因为 Java 虚拟机需要确保所获得的锁在正常执行路径,以及异常执行路径上都能够被解锁。

方法

synchronized 标记方法时, 字节码中方法的访问标记包括 ACC_SYNCHRONIZED 表示进入该方法时虚拟机需要进行monitorenter操作,退出或异常时需要进行monitorexit操作 这里 monitorenter 和 monitorexit 操作所对应的锁对象是隐式的。对于实例方法来说,这两个操作对应的锁对象是 this;对于静态方法来说,这两个操作对应的锁对象则是所在类的 Class 实例。

原理:

关于monitorenter和monitorexit的作用,我们可以抽象地理解为每个锁对象持有一个锁计数器和一个指向持有该锁的线程的指针

当执行monitorenter操作时,如果目标锁对象的锁计数器为0时,代表可以获取锁资源,虚拟机会将该锁的持有线程设置为当前线程,并将计数器加1。

目标锁对象的计数器不为0时,如果持有锁的线程是当前线程,则计数器加1(可重入),如果不是,则需要等待,直至锁计数器为0

当执行monitorexit时,锁计数器减1,锁计数器为0时,代表锁资源释放

对象内存布局

image.png 每个对象都有一个对象头 ,对象头包含以下信息

  • 标记字段(Mark word) :记录 hash码,锁状态,分代年龄 占用8字节
  • 类型指针(klass point ):指向该对象的类,确认是哪个类的实例 占用8字节

64位虚拟机默认开启-XX:+UseCompressedOops,即压缩指针, 将类型指针压缩成32位的,使得对象头的大小从16字节降至12字节

开启压缩指针需要内存对齐 (-XX:ObjectAlignmentInBytes,默认值为 8) ,对象的起始地址需要对齐至8的倍数。如果一个对象用不到8N字节,空白的空间称之为对象间的填充(padding/object alignment gap

OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4        (object header: class)    0xf800c105
 12   4        (object alignment gap)    
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

标记字段的最后两位用于描述锁状态

  • 00 轻量级锁
  • 01 无锁/偏向锁 biased_lock偏向锁标记位为1为偏向锁 为0为无锁
  • 10 重量级锁

偏向锁/轻量级锁/重量级锁的演变过程

  1. 当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。

  2. 当进行加锁操作时,Java 虚拟机会判断是否已经是重量级锁。如果不是,它会在当前线程的当前栈桢中划出一块空间,作为该锁的锁记录(Lock Record),

  3. 根据对象头中mark word 的biased_lock判断是否支持偏向锁,如果支持,epoch,偏向锁标记等拼接当前线程id,与mark word做异或运算,如果一致,代表偏向锁被自己持有,不处理,如果不一致,cas修改为无锁状态(方法未结束,后续升级为轻量/重量级锁) 或 尝试cas修改mark word线程id为当前线程 ,如果失败,进入偏向锁撤销

  4. 偏向锁撤销

持有偏向锁的线程不会主动释放偏向锁。当其他线程进行竞争时,触发偏向锁撤销。
撤销先尝试不在安全点使用CAS修改Mark Word为无锁状态,若无法撤销则考虑在安全点撤销,等安全点是比较低效的操作

1. 等待全局安全点,持有偏向锁的线程如果活跃且在还在执行同步代码块中的代码,
  将偏向锁升级为轻量级锁,偏向线程内部的所有相关的lock record 的displaced mark word置为null,
  最高lock record 的displaced mark word设置为无锁状态,对象头mark word指向该lock record 。没有cas,
  因为当前在安全点。 该线程继续持有轻量级锁
2. 如果线程已消亡或未执行同步代码块中的代码则直接撤销偏向锁,设置为无锁状态。
  1. 非偏向锁状态判断
1. 如果是无锁状态,拷贝mark word到Lock Record 的_displaced_header,用以存储无锁状态的Mark Word,待释放锁时恢复 ,
cas修改mark word指向lock record,如果成功,代表获取锁成功,如果失败,锁升级为重量级锁,
2. 如果是轻量级锁,判断是不是当前线程之前获取了锁,
     如果是则代表重入 _displaced_header设置为null,此种情况下,lock record会有多个,至少2个,取消锁时如果
     _displaced_header为null,不操作,否则cas 将mark word修改为无锁状态 (之所以取消锁还要cas是因为有可能其他线程将
     锁级别提升为重量级锁,已将mark word指向了 ObjectMonitor了);
   如果不是则升级位重量级锁,调用inflate锁升级为重量级锁,进入自旋/阻塞

5.轻量级锁升级:

1. 获取一个可用的ObjectMonitor对象
2. cas尝试将锁对象标记为膨胀中状态,如果失败表示有其他线程正在膨胀中,自旋等待膨胀完成
3. cas成功,设置锁对象头 mark word为重量级锁,指向monitor对象  

6.重量级锁进入及释放:

锁进入:
1.根据monitor owner判断是否重入,如果重入 recursion +1
2.如果线程是之前持有轻量级锁的线程,将monitor 的owner指向当前线程,recursion设为1,获取锁成功并返回
3.如果不是,自旋尝试 cas 将monitor的owner置为当前线程。
  失败将线程封装为ObjectWaiter对象 cas 到monitor的 _cxq队列头节点,再度尝试自旋获取锁,失败后调用
  park函数挂起当前线程,进入BLOCKED状态。

锁释放:
1.recursion减至为0
2.根据策略从_cxq或EntryList获取头节点,调用unpark唤醒线程。唤醒的线程继续竞争monitor

一个ObjectMonitor对象包括两个同步队列(_cxq和_EntryList) ,以及一个等待队列_WaitSet。
cxq、EntryList 、WaitSet都是由ObjectWaiter构成的链表结构。其中,_cxq为单向链表,_EntryList为双向链表。
调用object.wait后进入waitset队列尾节点,notify后移动到cxq或者EntryList队列,进入BLOCKED状态

个人实验数据:
多个线程竞争锁,最后进入_cxq队列的锁释放后优先获取锁
多个线程wait 进入waitset队列,
notify 提取waitset头节点,进入_cxq队列/EntryList,所以优先唤醒的是先wait的线程
notifyall依次提取waitset头节点,塞入_cxq队列/EntryList头节点,所以优先唤醒的是后wait的线程

偏向锁、轻量级锁、重量级锁对比

轻量级锁相比重量级锁的优势:

  1. 每次加锁只需要一次CAS
  2. 不需要分配ObjectMonitor对象(所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质 Monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的转换,成本非常高 )
  3. 线程无需挂起与唤醒

偏向锁对比轻量级锁的优势:

在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。

偏向锁、轻量级锁、重量级锁差异点:

  1. 偏向锁和轻量级锁的"锁"即是Mark Word,而重量级锁的"锁"是ObjectMonitor,此时Mark Word 指向ObjectMonitor。

  2. 偏向锁和轻量级锁依靠Lock Record个数来记录重入的次数,而重量级锁通过 ObjectMonitor里的_recursions 整形变量记录。

  3. 偏向锁和轻量级锁的重入只需要做简单的判断即可,而重量级锁需要通过CAS判断是否是重入