java两种同步机制的实现 synchronized和reentrantlock
我们知道,java是一种高级语言,java运行在jvm中,java编译器会把我们程序猿写的java代码编译成.class文件,这个.class对于jvm就是相当于汇编对于操作系统(jvm也有类似操作系统一样的指令集),当jvm运行的时候,它会把.class翻译成操作系统认识的指令集然后运行在操作系统(这里对java是解释型还是编译型语言不做深究),当然除此之外,java还可以通过jni(java native interface)调用c++,c的代码(c++,c还可以内嵌汇编代码,所以java是可以间接调用汇编的),jvm内存模型中有一个是Native Method Stacks,这里就是我们调用本地方法的栈,我们今天讲的java同步,就同时和java指令集和jni有关,下面进入正题。
当在java中使用多线程的时候,我们肯定要考虑到线程安全,线程安全简单说就是多个线程操作同一个变量不会出现结果不确定性,那为什么会出现线程不安全?那是因为java的内存模型决定,jvm有一个主内存,还有线程独有的线程上的工作内存,我打个比方,一个线程就是一个cpu,cpu有自己的多级缓存,还有一个内存条的内存。下面就盗个图简单说明一下:
因为这个原因,所以当多个线程修改同一个变量的时候,会出现不确定的结果,这就导致了线程不安全,那怎么样我们才能确保线程安全呢?说的通俗一点,就是排队,没错,当多个线程需要操作同一个变量的时候,排队一个一个来,那要怎样才能实现排队?机器不像人这么聪明,看到前面有“人”就站着等,那最简单粗暴的方法就是加锁,可以想象一下,一个线程要去修改一个变量的时候,前面有一道门,当门被锁住的时候,线程就只能等待或者干其他事。到了java的世界,在语言层面,我们有两种方法去实现我们锁的功能,下面就是我要重点讲的东西,第一个是jdk自带synchronized(关键字),这个就是前面说的利用java自己的指令集实现的锁,第二个是我们Doug Lea 大神主导的并发包中的 reentrantlock(类),这个底层就是通过jni是调用了虚拟机中的C++代码(parker类),下面我们就依次对这两个"东西"进行详细的展开。
synchronized,顾名思义,提供同步的语义,在java语言层面中,可以通过synchronized来控制不同的作用域,可以是类,可以是对象,可以是方法,可以是代码块,我们来看它是怎么来实现锁的功能的,我们直接实战,我先写一个测试类:
public class SyncTest {
Object lock = new Object();
public void sync(){
synchronized (lock) {
System.out.println("get lock");
}
}
}
很简单,我们先new了一个锁,当多个线程需要去调用sync方法然后输出信息的之前,需要这个锁打开才能输出,这是一段典型的同步代码块代码,前面说了,synchronized是利用jvm自带的指令集来实现锁的功能的,那我们现在就利用java自带的反编译工具(javap
-v SyncTest.class),把指令集输出来,我们重点分析这个sync方法:
这个就是反编译class文件的sync方法的结果,下面我们一行一行来分析:
descriptor:()V,这行是方法的说明,表示这个方法没有入参,返回类型是void
flags:ACC_PUBLIC,说明是public方法
code:说明下面是方法代码区域
stack=2,locals=3,args_size=1,表示这个执行这个方法虚拟机栈深度只需要2,本地变 量有3个,有1个参数,1个参数就是this(java方法的第一个参数都是this,只不过隐藏掉了)
aload_0:装载第一个局部变量到操作栈,这里就是this
getfield #3 :#3指向常量池第三个位置,我没有贴出常量池的反编译视图,在这里就是代 码中lock对象,整行指令意思就是访问这个lock对象的引用
dup:复制上面getfield获取的引用压入栈,这里就是lock的引用
astore_1:弹出栈顶的引用,然后放入局部变量1的位置中
monitorenter:得到lock对象的monitor,monitor的进入计数count+1,这个指令就是我们
Synchronized在jvm的底层实现,线程在打印之前需要得到lock的monitor,如果获取不 到,则被挂起,获取到了就继续执行,在monitorenter下面,还有操作系统级别的 mutex重量级锁以及jvm利用cas优化的jvm级的轻量锁,这个不在我们这次讨论范围。
getstatic #4:访问静态变量System.out:PrintStream
ldc #5:将常量池第5个常量“get lock”压入栈
invokevirtual #6:执行数据输出函数
aload_1:装载第二个局部变量,这里就是astore_1从栈上弹出的lock对象的引用
monitorexit:monitor的计数-1,因为是可重入的,就是一个线程可以多次拿到monitor, 所以当monitor计数为0的时候释放lock锁。
goto 25:看一下25行是return,就是返回,如果代码正常执行,那么久流程就结束了,如 果代码中间出现了异常,则继续走
astore_2:弹出栈顶引用,放到局部变量2
aload_1:装载第二个局部变量,这里就是astore_1从栈上弹出的lock对象的引用
monitorexit:monitor的计数-1,出现异常也释放掉锁。
aload_2:装载第三个局部变量,这里就是astore_2从栈顶弹出来的引用。
athrow:将栈顶的数据作为异常抛出,如果为null,则抛空指针异常
return:结束本方法调用的栈帧
下面的异常表,调试行信息和栈图就不解释了,我也不是很懂,以免误导,想深入研究可以找资料学习
到这里,这个方法在jvm指令层面的执行顺序就结束了,java 关键字Synchronized就是通 过monitorenter,monitorexit两个指令来实现的,在monitor下面还有jvm通过cas优化过2的轻量锁以及操作系统级别的重量互斥锁mutex,这个本期不做讨论。
reentrantlock:这个锁在java层面就是一个类,不像上面的Synchronized是一个关键字他是doug lea大神在 java1.5中主导的因为解决那个时候Synchronized效率低下的出现的,这个类可以有很多东西讲,在这里我抽重点讲,首先在java层面,reentrantlock以及其他同步工具类,比如ConcurrentHashMap(java 1.8改动很大,没仔细看源码,好像和以前不太一致了,1.8以前是),CountDownLatch,CyclicBarrier,以及信号量 semaphore,在底层都是通过实现抽象类AbstractQueuedSynchronizer(AQS)来实现各种锁功能,比如乐观锁,悲观锁,互斥锁,共享锁,读写锁等。先来说一下reentrantlock的lock实现原理。
reentrantlock.lock:reentrantlock类自己实现了AQS,内部有两种实现方式,有公平实现 类FairSync和不公平实现类NonfairSync,这里我们说默认的使用不公平sync:
lock方法首先会用CAS采用乐观锁方式获取一次锁,(CAS操作线程安全,因为通过jni直接调用的操作系统cmpxchg指令,(如果是多核CPU,则需要调用lock cmpxchg利用内存屏障来保障指令的原子性)如果成功了,则设置当前线程占有这个锁,如果失败了,则去acquire(1),意思是提交一个获取锁的申请:
首先tryAcquire(1),调用到NonfairSync的nonfairTryAcquire方法:
首先获取一下state,如果是0则说明还没占有锁,则再去CAS获取一次,意图是代码从外面的CAS走到这的时间里,可能别的线程已经释放了锁,所以在进行一次CAS,如果获取到了则返回成功。如果state不是0,且占有锁的线程是当前线程,因为是可重入的锁,则在计数上加1,代表重入一次,等到时候释放锁的时候,要全部释放完等计数为0才表示释放完全。
如果都不是以上情况,则返回false,进行后面的操作:
new一个当前线程的node,然后通过CAS操作,把这个node排在node双向链表的尾部,如果CAS失败,则进行enq:
enq很简单,一个死循环既自旋,如果链表是空的,则初始化,否则一直CAS到node排到链表尾部为止。添加等待node成功之后,执行以下方法:
进行循环,Node p就是当前线程node前面的一个node,如果前一个node就是链表头结点以及马上进行一次CAS,如果成功获取到锁了,那么把当前线程的node设置成head,且断开当前node和前node的链,返回fase,表示获取到锁。如果不是,则进行下一步:
这个方法的目的是返回当前线程是否应该被阻塞,pred node就是当前线程node的前面一个等待node,注释说的很清楚,如果前面的node状态是signal,表示当前线程可以安全的park住,等待被唤醒,如果WS>0就是取消状态,如果前置node是取消状态,则双向链表往前回溯,直到找到当前node的前置node的状态不是取消状态,然后把当前阶段的前置node指向这个节点,如果是其他状态,则要把前置阶段设置成signal状态,等下次循环到这个方法的时候,就可以判断出这个节点是需要park的,当判断需要阻塞线程之后,执行:
这个方法就是调用了unsafe类的park方法,unsafe通过JNI调用了本地的C++实现的mutex锁,这个也不做展开。当这个线程被unpark唤醒的时候,返回的线程的中断状态。到这里,reentrantlock的非公平锁的原理就说完了,公平锁的原理比这个还要简单一些,想知道原理的可以自行查阅资料研究。说到底,reentrantlock以及其他的concurrent包下的同步类,在java层面都是利用集成了AQS的子类,去实现锁,怎样锁则是通过unsafe类调用了C++代码实现的mutex互斥锁。释放锁unlock操作,则是通过释放当前线程的充入数量,然后通过之前的双向链表,通过unpark方法去唤醒next node,当next node被唤醒之后,怎会继续执行
这个代码块,如果这个时候没有其他的线程去抢占这个锁,那么tryAcquire操作就会成功占有锁,如果这个时候有另外一个线程抢先CAS成功了,则这线程继续阻塞,因为这个是非公平锁。
到这里,锁就java的两个锁就说完了,总结一下,synchronized关键字是通过对象的monitor,然后通过monitorenter和monitorexit jvm指令来完成的。而Doug lea大神编写的concurrent包的同步类,在java层面通过继承AQS,然后配合unsafe提供的CAS操作以及链表这个数据结构,去实现乐观锁,互斥,共享,自旋等锁,当需要阻塞线程的时候,通过unsafe类调用操作系统的mutex互斥锁去实现阻塞。
总体已经说完了,java多线程并发涉及的知识点如果要深挖其实还有非常多,如果要一篇说全,包含了太多东西,java内存结构,jvm虚拟机,指令集,第一没这么多时间,很多大神用一本书都说不完,第二,本人知识储备还不够,没掌握的东西就不说了,以免误导,这边文章就介绍java的两种同步机制,synchronized以及reentrantlock。
如有不对的地方,希望可以及时指正,后面我会抽时间再来讲一下java的内存模型,内存分区,垃圾回收机制,以及多线程,线程池等相关的知识点,用来记忆和巩固,28岁了,有些东西不常用的话会忘。。。