Java并发-volatile和synchronized
学习自Java并发编程的艺术和深入理解Java虚拟机
volatile
定义:
volatile是轻量级的synchronized,被volatile修饰的变量被修改时能够被其他线程及时发现更新,确保所有线程看到这个变量是一致的。但是volatile只能确保可见性不能保证互斥性
实现原理:
有volatile修饰的变量在写操作的时候会多出一条Lock前缀的汇编代码,该代码有两个作用
-
将当前处理器缓存行的数据写回到系统内存
-
使其他CPU缓存了该内存地址的数据失效,当其他CPU需要对这个数据进行修改的时候,会重新从主内存读取数据到CPU缓存中
-
缓存一致性协议
。。。volatile的其他作用后续再讨论
synchronized
定义和实现原理
刚刚说了上面的volatile只能做到可见性但不能做到互斥性,synchronized能够做到可见和互斥性,synchronized可以实现互斥同步确保多线程正确执行。使用synchronized有三种形式
- 用在普通方法上,锁住的是当前实例对象
- 用在静态方法上,锁住的是当前类对象
- 用在代码块上,锁住的是synchronized括号里配置的对象
同步代码块:monitorenter 指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,JVM 需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个 Monitor 与之相关联,当且一个 Monitor 被持有之后,他将处于锁定状态。线程执行到monitorenter 指令时,将会尝试获取对象所对应的 Monitor 所有权,即尝试获取对象的锁,如果对象没有被锁定或者当前线程拥有该对象的锁,锁的计数器加一,执行monitorexit指令时,锁的计数器会减一,当锁的计数器为0时,锁也就被释放了。如果获取对象锁失败,那么当前线程就会被阻塞,直到对象锁被释放为止。synchronized是可重入的,不会自己把自己锁死。
同步方法:synchronized 方法则会被翻译成普通的方法调用和返回指令如:invokevirtual、areturn指令,在 VM 字节码层面并没有任何特别的指令来实现被synchronized修饰的方法,而是在 Class 文件的方法表中将该方法的access_flags字段中的synchronized 标志位置设置为 1,表示该方法是同步方法,并使用调用该方法的对象或该方法所属的 Class 在 JVM 的内部对象表示 Klass 作为锁对象。
锁的语义
public class LockExample {
private int a = 0;
private synchronized void write(){
System.out.println("write....");
a++;
}
private synchronized void read(){
System.out.println("read....");
System.out.println("a: "+a);
}
public static void main(String[] args) throws InterruptedException {
LockExample lockExample = new LockExample();
Thread ta = new Thread(new Runnable() {
@Override
public void run() {
lockExample.write();
}
});
ta.start();
Thread tb = new Thread(new Runnable() {
@Override
public void run() {
lockExample.read();
}
});
tb.start();
}
}
一开始,线程ta获取到锁,执行临界区的代码,执行完之后释放锁时,会把线程对应的本地内存中的共享变量刷新到主内存中
当线程tb获取锁的时候,线程tb对应的本地内存会被置为无效,从而使得被锁保护的临界区代码必须从主内存中读取共享变量
锁优化
Java的线程是映射到操作系统的原生线程上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态中,因此状态转换需要消耗很多的处理器时间。对于一些简单的同步代码,线程切换的时间可能比代码执行的时间还长,因此synchronized也被称为重量级锁
在JDK1.6之前synchronized的性能很低,1.6之后对锁做了很多优化
自旋锁/自适应自旋
上面提到线程的挂起和恢复的性能很低,所以如果一个锁的持续很短,那就可以让等待的线程不放弃的CPU的处理时间,让线程执行一个忙循环即自旋,看看持有锁的线程是否很快的就会释放锁。
如果锁被占有的时间很短,自旋锁就很有效果,但如果锁被占用的时间很长,一直在自旋那只会白白的浪费资源,CPU一直在空转,所以自旋不能无限制的自旋必须加以限制,超过限制还没获得锁就应该阻塞,自旋的限制默认是10次,可以使用-XX:PreBlockSpin来设置限制次数
自适应锁是自旋的次数不在是固定的而是固定的,它会根据上一次自旋锁的情况来决定这次的自旋次数或者是直接不自旋
锁消除/粗化
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除,把它们当做栈上的数据对待,认为是线程私有的
锁粗化,如果探测到有一串操作都对同一个对象加锁,将会把加锁同步的范围粗化到整个操作序列的外部,避免频繁的加锁解锁
对象头
对象头分为两个部分
内容 | 说明 |
---|---|
Mark Word | 存储对象自身的运行时数据,如哈希码,GC分代年龄,锁信息等 |
Class Metadata Address | 存储指向方法区对象类型数据的指针 |
Array Length | 存储数组的长度(如果当前对象是数组) |
Mark Word是实现偏向锁和轻量级锁的关键
Mark Word会根据对象的状态复用自己的存储空间
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码,对象GC分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC标记 |
偏向线程ID,偏向时间戳,对象GC分代年龄 | 01 | 可偏向 |
偏向锁
偏向锁是为了消除数据在无竞争情况下的同步原语,把整个同步消除掉,进一步提高性能,偏向的意思是该锁会偏向于第一个获得它的线程,如果在后面的过程中该锁没有被其他线程竞争,则持有偏向锁的线程永远不需要同步
开启偏向锁后,当锁对象第一次被线程获取时,JVM把锁对象的Mark Word中的标志为设置为 01 即偏向模式,然后通过CAS操作把获取到锁的线程ID存在Mark Word中
如果CAS操作成功,该线程以后进入临界区时都不需要进行同步操作
偏向锁使用了一种等到竞争出现才释放锁的机制,当有其他线程尝试获取这个锁的时候的,偏向模式就结束了。会首先暂停拥有偏向锁的线程,撤销偏向,然后根据持有偏向锁的线程是否活着,如果线程不处于活动状态,则将Mark Word设置为无锁状态(01),如果线程还活着,就会升级到轻量级锁(00)
参数-XX:-UseBiaseLocking来禁止偏向锁的优化
轻量级锁
轻量级锁是通过CAS,来在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
在代码进入同步块的时候,如果同步对象没有被锁定,会在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,也被称为displayed mark word
然后,虚拟机将使用CAS操作尝试将锁对象的Mark Word更新为指向Lock Record的指针,如果更新成功了,那么这个线程就拥有该对象锁,同时该锁对象的Mark Word的标志位更新为 00,即轻量锁状态,下图是CAS堆栈和锁对象的状态
如果更新操作失败了,首先会检查对象的Mark Word是否指向当前线程的栈帧,如果指向说明当前线程已经拥有该锁,可以进入临界区,否则说明这个锁对象已经被其他线程抢占了,自旋等待,如果自旋失败或者超过两个以上的线程在竞争该锁,那么轻量级锁就会膨胀为重量级锁,锁对象的Mark Word标志位更新为 10,同时Mark Word中存储的是指向重量级锁的指针,后面等待的线程都会阻塞。
轻量级锁的解锁过程也是通过CAS操作完成,会把锁对象的Mark Word和线程中的displayed mark word替换过来,如果成功则同步过程完成,如果失败(失败的原因是因为Mark Word更新成重量级锁了,丢失了之前指向Lock Record的指针),说明有其他线程尝试获取该锁,那就要在释放锁的同时,唤醒被阻塞的线程重新争夺锁访问同步块。