synchronize是什么?
synchronize是Java提供的一种保证线程安全的机制。
synchronize怎么使用?
synchronize关键字可以用来修饰方法或者代码块。
- 修饰普通方法或者代码块时,锁定的是当前的实例对象。
- 修饰静态方法或者静态代码块时,锁定的是当前类的class对象。
区别:一个类可以有多个实例对象,但只有一个class对象。多个实例对象的锁彼此不会冲突,class对象和实例对象锁也不会冲突。
synchronize原理?
在介绍synchronize原理之前,需要先介绍一下Java的对象结构,Java对象在JVM中分为三部分:
- Java对象头
- 实例数据
- 填充字段
其中Java对象头主要包含两部分内容:markword(用于存储对象运行时自身数据)和类型指针(用来指向类元数据)。
markword字段如下所示:
具体可以参考Java的对象结构:https://mp.csdn.net/postedit/96328116
synchronize的实现原理主要分为两部分:
- Java对象头
- monitor锁
Jvm会在Java每个对象创建时,给每个Java对象一把锁,也就是monitor锁,monitor里面定义了Entry Set和Wait Set属性,也就是传说中的锁池和等待池,monitor是由c++代码实现的,可以参考ObjectMonitor代码。
synchronize修饰的方法或者代码块实际上就是去获取对象的monitor锁,如果获取到了,进入方法内部执行,如果获取失败,则表明有别的线程正在执行该方法,当前线程进入Entry Set锁池中,等待正在执行的线程释放该锁。
Java对象头中的markword字段记录了当前对象的状态,包括无锁,轻量级锁,重量级锁等。线程就是根据对象的markword字段来判断能否获得monitor锁。
字节码层面:
将Java代码编译后,可以看到在字节码层面,synchronize的实现是在方法的入口的出口加入了monitorenter和monitorexit指令,这两个指令实际上调用的是JVM提供的8个原子指令中的lock和unlock指令。
为什么对早期的synchronize嗤之以鼻?
- 早期版本中,synchronize属于重量级锁,依赖于操作系统的Mutex Lock(互斥锁)实现
- 线程之间的切换需要从用户态转换到内核态,开销较大
在JDK1.6之后,对synchronize进行了很多优化,如自旋锁、自适应自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等等,性能有了较大的提升。
自旋锁
许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得,因此引入自旋锁
自旋锁是通过让线程执行忙循环等待锁的释放,不让出CPU
缺点:若锁被其他线程长时间占用,空循环会一直占用CPU,带来许多性能上的开销
自适应自旋锁
自旋锁的自旋次数是一定的,由于场景不同,不好设定,因此引入自适应自旋锁
自适应自旋锁是对自旋锁的优化,自旋次数不再固定,可以由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来动态决定
锁消除
锁消除是对synchronize的JVM层面的优化
JIT(即时编译,动态编译的一种)编译时,对运行上下文进行扫描,去除不可能存在竞争的锁。
例如如下代码:
package cn.lzx;
public class Demo2 {
public void run(){
String str1 = "123";
String str2 = "asd";
StringBuffer buffer = new StringBuffer();
buffer.append(str1).append(str2);
}
public static void main(String[] args) {
Demo2 demo2 = new Demo2();
demo2.run();
}
}
我们知道,StringBuffer是线程安全,是通过给方法添加synchronize关键字保证线程安全的,在如上代码中,由于run方法并没有将buffer返回,即别的线程不可能使用该buffer,即不存在线程安全问题,因此锁消除会将StringBuffer中的synchronize锁消除。
锁粗化
通过扩大锁的范围,避免反复加锁和解锁
看如下代码:
package cn.lzx;
public class Demo2 {
public void run(){
int i =0;
StringBuffer buffer = new StringBuffer();
while(i<100){
buffer.append(i);
}
}
public static void main(String[] args) {
Demo2 demo2 = new Demo2();
demo2.run();
}
}
如上代码,在循环体里,每次StringBuffer调用append方法都会进行加锁解锁操作,而锁粗化就是在循环体外面加一个synchronize锁,避免循环体里频繁加锁解锁。
偏向锁
减少同一线程获取锁的代价
大多数情况下,锁不存在多线程竞争,总是由同一线程多次获得
核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时markword的结构也变成偏向锁结构,当该线程再次请求时,无需做任何同步操作,即获取锁的过程只需要检查markword的锁标记位是否为偏向锁以及当前线程id是否等于markword的ThreadId即可,这样就省去了大量的有关锁申请的操作。
不适合锁竞争比较激烈的多线程场合
轻量级锁
轻量级锁时由偏向锁升级而来的,偏向锁运行在一个线程进入同步块的情况下,第二个线程加入锁争用时,偏向锁就会升级为轻量级锁。
适用场景:线程交替执行同步块。
若存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。
偏向锁、轻量级锁、重量级锁的对比
synchronize加锁的解锁的过程
加锁
- 在代码进入同步块时,如果同步对象锁状态为无锁状态,虚拟机会在当前线程的栈帧中创建一个锁记录(Lock Record)的空间,用于存储当前对象的mark word的拷贝。
- 拷贝对象头中的markword到Lock Record中。
- 拷贝成功后,使用CAS操作尝试将对象的markword更新为指向lock record的指针,并将lock record里的owner指向markword。
- 如果更新成功,那么当前线程则获取了该对象的锁,并将markword里的锁标志位设置为锁状态。
- 如果更新失败,则判断markword是否指向当前栈帧,如果是,则说明当前线程之前已经获取该锁,然后进入同步块继续执行,如果不是,则说明别的线程正在请求该锁,进行锁膨胀,根据锁状态,判断是进行自旋等待或者进入锁池中。
注:由以上过程,可以看出synchronize是支持重入的。
解锁
- 利用CAS操作尝试把当前线程中复制的markword对象替换当前对象中的markword。
- 如果替换成功,则同步完成
- 如果替换失败,则说明在该线程执行过程中,有其他线程尝试过获取该锁(此时锁已膨胀),那就要释放锁的同时唤醒其他线程。
锁的内存语义
从加锁和释放锁的过程,其实也能看出锁的本质
当线程释放锁时,Java内存模型会把该线程对应的本地内存中的共享变量刷新到主内存
当线程获取锁时,Java内存模型会把该线程对应的本地方法内存清空,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量
Java的内存模型可以参考:https://mp.csdn.net/postedit/96435122