一、什么是synchronized
java中的一个关键字,是一个内置锁,依赖于操作系统的mutex lock互斥锁实现。
通常我们用来协调线程同步的,保证同一时间只有一个线程可以访问该关键字修饰的代码块,从而达到保证线程安全的目的。
我们应该知道volatile也是一个关键字,它保证了程序执行的有序性,共享变量的可见性,但是并不保证原子性。
我们可以看成synchronized的出世是为了弥补volatile的不足,因为它可以保证原子性。
基于synchronized底层实现原理,它还具有可重入性。
在jdk1.6之前,synchronized实现的是重量级锁,并没有其他相关的锁优化机制,相比于java api锁 Lock来说性能较低。
在jdk1.6之后,引用了锁的膨胀升级机制,从无锁->偏向锁->轻量级锁->重量级锁,整个过程不可逆。引入了锁的优化机制,例如:
锁消除,锁粗化,自旋锁。针对锁的优化大大减低了锁的开销,目前性能已经可以与Lock持平。
二、synchronized使用
首先,synchronized既然是一个关键字,那么它必然通过修饰某些东西来加锁保证线程的串行化访问。具体可以修饰什么?修饰不同位置加锁的目标对象有何区别?
- 修饰实例方法,锁住的是调用此同步方法实例对象
public static void main(String[] args) {
Person personA = new Person("张三", 20);
for (int i=1;i<=5;i++){
new Thread(() -> {
//访问person实例的同步方法 此时锁住的是personA实例对象,
// 此时如果再有其他线程访问personA的同步方法 是获取不到锁的
personA.instanceMethod();
},"线程"+i).start();
}
}
static class Person{
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public synchronized void instanceMethod(){
System.out.println(Thread.currentThread().getName()+"与"+name+"在交流....");
try {
Thread.currentThread().sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+"与"+name+"交流完了");
System.out.println("======================================");
}
}
执行结果如下:
总结: 每个线程都是在执行完之后才能允许其他线程执行,说明此时锁住的是personA实例,如果该类还有一个personB实例,线程n访问personB的此方法,则允许访问,这就是只锁当前实例对象。
2.修饰代码块,锁住的是()括号里面的对象实例
public static void main(String[] args) {
for (int i = 1; i <= 5; i++) {
new Thread(() -> {
//调用的方法 内部有synchronized修饰代码块
personA.addCount();
}, "线程" + i).start();
}
}
static class Person {
private Integer count = 0;
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public void addCount() {
//修饰代码块
synchronized (this) {
System.out.println(Thread.currentThread().getName() + "对count +1" );
System.out.println(++count);
try {
Thread.currentThread().sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "操作完毕" );
}
}
}
执行结果:
总结:锁住的是()括号中的对象实例。
3.修饰静态方法,锁住的是类的所有对象,这很好理解,我们调用一个对象的静态方法,一般是ClassA.staticMethod()这样访问的,不需要拿到ClassA的实例再去调用,所以锁住ClassA类的所有对象。
public static void main(String[] args) {
for (int i = 1; i <= 5; i++) {
new Thread(() -> {
//访问静态方法
Person.staticMethod();
}, "线程" + i).start();
}
}
static class Person {
private Integer count = 0;
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
//修饰静态方法
public synchronized static void staticMethod(){
System.out.println(Thread.currentThread().getName() + "访问静态方法成功");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "退出静态方法");
}
}
执行结果:线程依次完整执行
总结:修饰静态方法是 锁住的是类的所有对象,但是不影响此类的实例访问同步方法,也就是说,在有线程访问静态方法时,此时另外其他线程访问这个类的实例方法(方法也被synchronized修饰),是被允许访问的。
4.修饰类,锁住的是类的所有对象与修饰静态方法相同。
三、实现原理
要知道synchronized内置锁一个对象锁,在java中每一个对象都有自己Monitor对象(监视器锁),而synchronized关键字就是基于这个监视器对象实现的互斥锁,在代码被编译时,synchronized关键字会被翻译成monitorenter和monitorexit两条指令插入代码块。
那么这个monitor对象是在什么时候使用的呢?
上文介绍过,在jdk1.6之后,引用了一套锁的膨胀机制,由无锁->偏向锁->轻量级锁->重量级锁,而monitor是在膨胀为重量级锁后才用到的,升级为重量锁之后,对象的对象头中会关联一个monitor对象的引用。
说道此处,加锁过程是怎样的?
1.加锁过程(重量级锁)
monitor对象内的属性说明:
-
_owner:指向持有ObjectMonitor对象的线程
-
_WaitSet:存放处于wait状态的线程队列
-
_EntryList:存放处于等待锁block状态的线程队列
-
_recursions:锁的重入次数
-
_count:用来记录该线程获取锁的次数
当A,B,C线程同时加锁,假设只有B获取到了锁,则_owner属性指向B,A和C放入_EntryList阻塞队列中等待被唤醒,_recursions=1,_count=1,此时如果B调用同一个对象的另一个synchronized方法,则_recursions=2,_count=2,退出同步块时,_recursions-1,_count-1,直到减为0,算是释放锁成功,并且唤醒队列中的其他阻塞线程。
_WaitSet:主要是存放在同步块中执行 wait 方法的线程。配合 EntryList 就是 对象 的 wait 和 notify(notifyAll) 的底层实现
那么既然锁是加在对象上的,那么锁的状态是如何记录的呢?
2.锁的状态记录
答案:锁的状态是记录在对象头中的,
那么问题来了,什么是对象头?
先来看看,对象的内存区域划分是怎样的?
对象头:在对象头中包括两部分,第一部分是mark word 用于存储对象自身运行时的数据,例如GC分代年龄,对象的hashCode码,锁状态标志,偏向锁状态,偏向线程ID等,而锁状态的记录就是记录在此部分,
这部分数据长度在32位和63位的虚拟机中分别为32bit和64bit,官方称它为"Makr Word"。对象要存储的运行时数据很多,其实已超出32位、64位Bitmap结构所以记录的限度,但对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息。它会根据对象状态复用自己的存储空间。第二部分存储的是对象的元数据信息,例如:类信息,类属性,对象大小等。数组的长度无法在元数据中获取到,所以需要专门保存一块区中。
在mark word区域,会随着程序的 运行,32位bit存储的内容会跟着变化,如下图 锁状态的记录主要靠最后3bit
偏向锁:
偏向锁是在jdk1.6后引入的锁,是一种锁优化机制。一般在大多数情况下,锁都不会存在多线程竞争的情况,而且都是有同一个线程多次获得,因此为了减少获取锁的开销而引入的偏向锁。
偏向锁的核心思想:在没有多线程竞争时,一个线程获取到了锁,则进入偏向模式。此时 是否是偏向锁由0->1,锁标志位更新为01,Mark word中的线程ID指向当前线程。
锁进入偏向模式后,说明此时没有多线程竞争,锁偏向当前线程,当前线程再次获取锁时,不必再次申请锁资源,节省相关的开销。
轻量级锁:
此时如果有另外线程尝试进入同步块,判断如果当前的锁是偏向锁,则会使用cas操作,把Mark Word中的线程ID更新为自己,如果更新失败,进入偏向模式失败,此时不会立即更新为重量级锁,而是使用一种优化手段,进入轻量级锁。
升级为轻量级锁后,锁标志位更新为00,mark word中其他区域存储指向线程栈中锁记录的指针。
轻量级锁的核心:在整个同步周期,多线程交替执行,不存在竞争环境,一旦多线程同时竞争锁,则会立即膨胀升级为重量级锁。
当然,不存在竞争也不是绝对的,而是存在竞争时,获取锁失败后,不会立即在操作系统层面挂起,而是假设在不久的将来能够获取到锁,因此线程会先通过自旋的方式等待,避免线程挂起,自旋次数不会太多,一般在50-100次左右。这样可以避免因为线程挂起,导致的线程切换带来的额外开销。
当然这是基于线程持有锁的时间都不会太长,如果直接挂起线程可能会得不偿失,这就是自旋的优化方式,提高效率。
如果自旋之后还是无法获取到锁,则会在操作系统层面挂起。最后没办法则会膨胀为重量级锁。
重量级锁:
在多线程竞争的环境中,直接加的锁未重量级锁。重量级锁可以是经过偏向锁/轻量级锁这种优化手段失效后升级来的,也有可能是一上来就会加重量级锁。
进入重量级锁后,mark word的锁标志位更新为10,此时与对象对应的monitor监视器锁内部开始记录相关数据(具体见上文加锁过程)。
四、synchronized优化
在上文提到的锁的膨胀升级机制,自旋锁等,是jdk1.6之后引入的最主要的优化手段。
除此之外,锁消除,所粗化也能大大提升锁的性能。
所消除:
什么是所消除?举个例子。
public class ClassA{
public synchronized void test(){
//代码逻辑段
}
}
public class ClassB{
public void methodA(){
//在某个方法内部 实例话对象
ClassA a = new ClassA();
//调用a的同步方法,此时不会申请锁
a.test();
}
}
以上代码中可以看出,ClassA的实例a,在methodA方法内部被创建,然后内部调用,这种情况是不存在竞争的,因为a实例是在线程栈中的,所有的线程栈都有自己独立的一份a实例,而此时是对实例a加锁,所以此时不存在锁竞争,这时如果还要加锁,是不是就多此一举了呢?
所以在这种情况jvm是不会为我们加锁的,这样也会大大减少加锁带来的开销。
而具体jvm是如何能够分析出来的呢?这就涉及到了对象的逃逸分析?
使用逃逸分析,编译器可以对代码做如下优化:
- 同步省略(锁消除),如果一个对象被发现只能被一个线程访问,那么对于该对象的操作不考虑同步。
- 将堆内存分配转为栈内存分配,也就是如果判断一个对象只在线程栈中使用了,不会被传到线程栈之外,则对象的内存分配将会在栈内存中,而不是堆内存中,这也就是我们说的不是所有的对象都在堆中分配内存。
在jdk1.7之后默认开启了逃逸分析,如果需要关闭:-XX:-DoEscapeAnalysis。
锁粗化:
锁粗化 说白了,本来用多个synchronized关键字修饰的地方 自动优化合并成一个,例如:
public class StringBufferRemoveSync {
public void add(String str1, String str2) {
synchronize(){ //操作1 }
synchronize(){ //操作2 }
synchronize(){ //操作3 }
}
//锁的粗化
public void add(String str1, String str2) {
synchronize(){
//操作1
//操作2
//操作3
}
}
}
图中,把操作1,操作2,操作3合并到一起由需要加三把锁有华为加一把锁,节省了两次锁的开销,这就是锁的粗化。