引言
上一篇我们复习了线程的基本状态及三种实现方式,本篇我们继续来复习下synchronized关键字,synchronized是解决java并发问题最简单、最常用的方法之一,主要的作用是保证线程互斥的访问同步代码。
1、synchronized 原理
synchronized底层是通过一个monitor(监视器)的对象来完成的,java中每个对象都有一个monitor,monitor的初始值为0,当线程进入时monitor加1,线程退出时monitor减1,此时monitor重新回到初始值0,其它线程才能再次进入。
从上面synchronized底层加锁过程,我们可以知道:在java中,每一个对象有且仅有一个同步锁。这也意味着,synchronized同步锁是依赖于对象而存在。
这里再补充一个知识点:由于Java中的线程和操作系统原生线程是一一对应的,线程被阻塞或者唤醒时时会从用户态切换到内核态,从内存语义来说,加锁的过程会清除工作内存中的共享变量,再从主内存读取,而释放锁的过程则是将工作内存中的共享变量写回主内存,这种转换非常消耗性能。
下面我们通过反编译一段代码来看看底层的实现,java源代码:
public class SynchronizedTest {
public void test(){
synchronized (this){}
}
}
反编译的字节码:
//上面省略部分字节码……
public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_1
5: monitorexit
6: goto 14
9: astore_2
10: aload_1
11: monitorexit
12: aload_2
13: athrow
14: return
//下面省略部分字节码……
从上面的反编译字节码中,我们关注到标记3处有一个monitorenter表示获取了monitor的所有权,标记5处有一个monitorexit表示释放了monitor的所有权,可能有的同学注意到标记11处也有一个monitorexit,会不会导致monitor减2次导致为-1了?
答案肯定是不会的,我们再看看标记6处的goto,跳转到了标记14的return执行完毕返回了,不会执行到标记11,标记11处的monitorexit只有程序执行异常的时候才会执行,所以不管程序怎么执行,都会最后释放monitor的所有权。
2、深入源码
实际上说到这里,已经算是讲完了,但是为了说得更加清楚,再深入到源码来说,synchronized实际上有两个队列waitSet和entryList,这里就不贴具体的代码了,只说一下简单的流程:
- 当多个线程进入同步代码块时,首先进入entryList。
- 有一个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1。
- 如果线程调用wait方法,将释放锁,当前线程置为null,计数器-1,同时进入waitSet等待被唤醒,调用notify或者notifyAll之后又会进入entryList竞争锁。
- 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null
3、锁的优化机制
从JDK1.6版本之后,synchronized本身也在不断优化锁的机制,有些情况下他并不会是一个很重量级的锁了。优化机制包括自适应锁、自旋锁、锁消除、锁粗化、轻量级锁和偏向锁。
锁的状态从低到高依次为无锁->偏向锁->轻量级锁->重量级锁,升级的过程就是从低到高,降级在一定条件也是有可能发生的。
自旋锁:由于大部分时候,锁被占用的时间很短,共享变量的锁定时间也很短,所有没有必要挂起线程,用户态和内核态的来回上下文切换严重影响性能。自旋的概念就是让线程执行一个忙循环,可以理解为就是啥也不干,防止从用户态转入内核态,自旋锁可以通过设置-XX:+UseSpining来开启,自旋的默认次数是10次,可以使用-XX:PreBlockSpin设置。
自适应锁:自适应锁就是自适应的自旋锁,自旋的时间不是固定时间,而是由前一次在同一个锁上的自旋时间和锁的持有者状态来决定。
锁消除:锁消除指的是JVM检测到一些同步的代码块,完全不存在数据竞争的场景,也就是不需要加锁,就会进行锁消除。
锁粗化:锁粗化指的是有很多操作都是对同一个对象进行加锁,就会把锁的同步范围扩展到整个操作序列之外。
偏向锁:当线程访问同步块获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程ID,之后这个线程再次进入同步块时都不需要CAS来加锁和解锁了,偏向锁会永远偏向第一个获得锁的线程,如果后续没有其他线程获得过这个锁,持有锁的线程就永远不需要进行同步,反之,当有其他线程竞争偏向锁时,持有偏向锁的线程就会释放偏向锁。可以用过设置-XX:+UseBiasedLocking开启偏向锁。
轻量级锁:JVM的对象的对象头中包含有一些锁的标志位,代码进入同步块的时候,JVM将会使用CAS方式来尝试获取锁,如果更新成功则会把对象头中的状态位标记为轻量级锁,如果更新失败,当前线程就尝试自旋来获得锁。
整个锁升级的过程非常复杂,我尽力去除一些无用的环节,简单来描述整个升级的机制。
简单点说,偏向锁就是通过对象头的偏向线程ID来对比,甚至都不需要CAS了,而轻量级锁主要就是通过CAS修改对象头锁记录和自旋来实现,重量级锁则是除了拥有锁的线程其他全部阻塞。
4、对象锁和类锁
对象锁和类锁可能是开发中最容易混淆的,下面通过一个伪代码来说明下
pulbic class ObjectTest {
//方法1
public synchronized void syncA(){}
//方法2
public synchronized void syncB(){}
//方法3
public static synchronized void syncC(){}
//方法4
public static synchronized void syncD(){}
}
方法1和方法2都是对象锁,方法3和方法4都是static修饰的类锁,如果有两个实例x,y,那么下面的4个方法能否同时访问
- x.syncA和x.syncB
- x.syncA和y.syncA
- x.syncC和y.syncD
- x.syncA和ObjectTest.syncC
验证的代码限于篇幅就不贴出来了,下面是结论:
- 不能被同时访问。因为syncA()和syncB()都是访问同一个对象(对象x)的同步锁。
- 可以同时被访问。因为访问的不是同一个对象的同步锁,x.syncA()访问的是x的同步锁,而y.syncA()访问的是y的同步锁。
- 不能被同时访问。因为syncC()和syncD()都是static类型,x.syncC()相当于ObjectTest.syncC(),y.syncD()相当于ObjectTest.syncD(),因此它们共用一个同步锁,不能被同时反问。
- 可以被同时访问。因为syncA()是实例方法,x.syncA()使用的是对象x的锁;而syncC()是静态方法,ObjectTest.syncC()可以理解对使用的是“类的锁”。因此,它们是可以被同时访问的。
5、总结
synchronized的优化是在jdk底层进行的,每个版本都会有差异,我们只要记住,在java中,每一个对象有且仅有一个同步锁,当我们调用某对象的synchronized方法时,就获取了该对象的同步锁,不同线程对同步锁的访问是互斥的。
如果你觉得本篇文章对你有帮助的话,请帮忙点个赞,再加一个关注。