深度解析Java中的那把锁

时间:2021-07-10 06:25:02

锁的本质

我们先来讨论锁的出现是为了解决什么问题,锁要保证的事情其实很好理解,同一件事(一个代码块)在同一时刻只能由一个人(线程)操作。

这里所说的锁为排他锁,暂不考虑读写锁的情况

我们在这里打个比方,假设有10个人要过独木桥(独木桥只能承载一个人的重量),他们可以排好队一个一个的过,后面一个人看到前面过去了之后他便跟着过去,直到所有的人都过去。

那如果我们用计算机模拟这个过程呢,没错,我们的程序不会排好队,更不会有看到前面的人已经通过这种主观能动性。所以这有点类似于所有的人都是蒙着眼睛的,但他们的听力是良好的,如果有人过去了之后在桥的另一头大喊一声“我已经通过了”,其他人便开始争着喊“下一个我过”。如果两个人几乎同时喊,在现实中我们很难搞清楚谁先谁后,甚至两个暴躁的人会打起来。但在计算机中他们不会,他们都如此听话如此可靠,而且在时间上总会分清谁先谁后,不会出现同时喊的状况。

我们先来总结一下这个过程正常工作的两个先决条件

  • 同一时刻,只能有一个人抢到锁(过桥的权利)
  • 当操作完成之后,必须释放锁(过去桥之后,要告诉其他人现在可以过桥了)

很简单,对吧,但锁的真正意义就在于此,只是不同的场景对这两点有不同的实现方式罢了。

Java中的锁

可见性

提到Java中的锁,就不得不提Java的内存模型,如下图(假设在多核CPU上),这里可以使CPU的一个核心类比一个线程(这是一个简化的模型,事实上比这个模型复杂的多):

深度解析Java中的那把锁

注意:这里只是类比,CPU的Cache与JMM中的工作内存并不严格一致,但两者有一定交集,在这里做这样的类比并不会误导我们想要的出来的结论

读到这里或许有的读者会有问题,为什么CPU要把数据抓到工作内存去,而不是直接从主内存里面拿呢。这要从计算机的组成原理上讲起,CPU和内存在物理上是分离的,CPU从主存抓取数据如同远房探亲。从主内存中抓取数据比对数据的操作上要快数十倍甚至上百倍。这有点像你想和小明打一个小时游戏,但是要花几天甚至数十天的时间把小明请过来。这样为了省时间,我们可以把小明请过来,多打几天游戏在让他回去。事实上也的确如此,我们的CPU之所以要在Cache 中操作之前Fetch过来的数据,就是为了节省这一段时间。但这就在两个Thread中产生两个副本,而他们互相不知道对方有没有更改过cache到的数据。但充满智慧的CPU架构师给出了这种通知的保证(MESI协议,这大概是相当早的分布式缓存一致性解决方案了),这个协议的原理比较复杂,在此不在赘述,但这并不影响我们对锁的理解。我们只要知道,操作系统提供了这样的支持,并留给了system callnative api就足够了。这个方案解决了CPU对变量的可见性。在java中通过使用volatile实现变量的可见性保证,而其保证的原理正是借助与CPU的缓存一致性协议实现的,操作系统将其抽象为lock操作,

原子性

似乎有了可见性对元素的操作就完全可靠了,但事实并非如此,这取决于对变量进行操作的过程,我们i++为例说明这一点,但在此之前,我们先来看一下i++在Java中的执行过程

代码如下:

public class Test {
static int i=1;
public static void main(String[] args) {
i++;
}
}

我们使用javap -verbose Test.class查看Java中main方法的虚指令

0: getstatic     #2                  // Field i:I
3: iconst_2
4: iadd
5: putstatic #2 // Field i:I
8: return

这个过程的语义与下图相同

深度解析Java中的那把锁

考虑以下情况,在第三步回写的过程中算出的结果已经保留了,假设线程A在此卡顿了一会儿,其他线程已经更改了i的值,然后线程A才回过神来,但结果还是刚才算出的结果3,这时它进行回写操作的时候,就会覆盖其他线程对i的赋值,就会导致值的不一致现象。

可以看到,之所以会出现这种现象,就是因为i++这个操作没有像我们想象的那样,一下子就完成,而是分成了很多步。我们称这种操作为非原子性操作,就是i++操作的非原子性,导致在哪怕保证了变量的可见性的情况下仍然会导致数据操作相互覆盖(线程不安全)的情况。

隔离区(临界区)

终于讲到Java中的锁了,根据独木桥的例子,要想保证多个线程对变量的操作绝对安全,就要保证对变量操作的串行化。Java中使用synchronized关键字提供了前文提到过的两个先决条件。下面我们来详讲一下java中的synchronized关键字。我们先来看以下代码

public class Test {
public static void main(String[] args) {
synchronized (Test.class) {
}
}
}

同样使用javap -verbose Test.class

0: ldc           #2                  // class Test
2: dup
3: astore_1
4: monitorenter
5: aload_1
6: monitorexit
7: goto 15
10: astore_2
11: aload_1
12: monitorexit
13: aload_2
14: athrow
15: return

我们重点看monitorenter和monitorexit两个指令,根据我们前面所讲的两个先决条件,我们至少可以推断monitorenter在背后所做的事情有

  • 告诉其他线程,我拿到了锁(下一个过独木桥的人)

而monitorexit在背后做的事情当有

  • 告诉其他线程,我释放了锁(你们可以过桥了)

这里其实还有个问题,它标示锁的方式是什么,这就要提到java对象在内存中的模型了,事实上Test.class对象在内存中有个头部,通过设置这个对象头获取该对象的锁,而对这个锁的设置操作是用指令cmpxchg 保证原子性的,由操作系统和硬件底层支持。

事实上Java对锁进行了优化,包括偏向锁和轻量级锁。所以通不通知其他线程并不是那么绝对的,而且monitor背后所做的事情也绝对不是这么简单,在这个模型中,其他线程确认自己有没有获得锁是主动过来看Test.class的对象头有没有被设置为已获取锁状态。如果没有,自己就上锁。如果已经被锁住了,这个线程就需要发出system call 来阻塞自己,但Java自己做不了这件事情,它必须借助操作系统完成,借助操作系统发出system call到自己被阻塞这个过程需要几万的个时钟周期。而这个代价是相当昂贵的,对于CPU的执行速度来说,几万个时钟周期可以做很多的事情,这时如果我们乐观的认为,这个锁马上就能释放,我就愿意花费几百个时钟周期不停的判断这个锁是否释放,总比调用system call的开销要低一些,这就是乐观锁的原理。

synchronized释放锁之前,任何线程都不能进入synchronized的方法体内,不管在中间有多少操作,其他线程都必须等待操作完成之后释放锁的通知,这就保证了数据在多线程的绝对安全。

同时,在上面的字节码可以看出,当程序顺序执行时,在第6步monitorexit之后,会直接跳转到底15步返回,但若中间发生了异常,会在第12步先monitorexit然后,在抛出异常,这其实是编译器替我们完成了加锁和释放锁的过程,而且编译器替我们做了在发生异常的情况下也释放锁的保证。