在Java面试中,对锁的理解是一个重要的话题。
网上关于锁的技术层面的博文有一大堆,这里不再赘述。本文尝试从生活的角度去对Java中的锁机制进行解释说明,以便你能更好的理解和消化。
之前看过一个大神对锁的一个总结:锁的实现原理都是为了达到一个目的——让所有的线程都能看到某种标记。(这里暂停一会儿会儿,你可以细品一下这句话)
注意这句话的关键字:标记!
类比生活中的例子,你想想,不管是一般房门上的锁,还是保险箱的锁,亦或公共厕所中的锁,是不是都是一种标记?看到这种标记之后,你的第一反应是什么?~~~噢,这个坑位已经有人了,我用不了了。
联想一下生活,我们生活中为什么要使用锁?锁都有哪些类型?锁的使用方式是怎样的?......等等,这些关于锁的问题,你完全可以把Java中的锁带入到日常生活中进行类比,这样它就没那么难理解了。
1.锁的概念
在现实生活中,锁的概念我们可以类比为超市的储物柜。
假设一个超市有多个储物柜,每个储物柜就像Java中的共享资源。当一个人(线程)进入超市时,他首先会选择一个空的储物柜,然后把物品放入并锁上柜门(获取锁)。这时,其他人无法打开这个储物柜(其他线程无法访问已锁定的资源),从而保证了个人物品的安全性。
如果另一个人试图在同一时间打开同一个储物柜(另一个线程尝试访问已被锁定的资源),由于柜门已经被锁住,他将无法立即打开,只能等待第一个使用者使用完毕,打开柜门取出物品并解锁柜子(释放锁)后,第二个使用者才能获取到锁,进而使用这个储物柜。
在Java编程中,这种互斥机制同样存在,例如通过synchronized
关键字或者Lock
接口实现的锁,可以防止多个线程同时修改某个共享变量或执行特定代码块,从而避免因并发导致的数据不一致等问题。
你可以再找生活中的例子,对锁的概念进行自己的理解。
2.锁的类型
Java中的锁有好几个分类角度,可以是读写锁、可以是公平锁和非公平锁、也可以是悲观锁和乐观锁......等等。以下对常见分类进行说明,上边我们的实例场景在超市,接下来我们进入公共浴室。
-
公平锁与非公平锁:
- 公平锁就像是排队等候储物柜的规则,每个人都按照到达浴室的顺序依次使用储物柜。即使后来的人动作更快,也不能插队优先使用。
- 非公平锁则是没有严格排队的规则,刚空出来的储物柜可能会被任何一个等待的人迅速占有,即使他们并非下一个到达的人。在高并发情况下,非公平锁可能带来更高的效率,但也可能导致某些线程长时间等待,形成“线程饥饿”。
-
可重入锁:
- 类似于一个人可以在拥有某个储物柜钥匙的同时,再次打开并关闭这个储物柜(递归调用加锁的方法)。在Java中,
synchronized
关键字修饰的方法默认是可重入的,意味着持有锁的线程可以再次进入同步代码块,而不必担心自己把自己锁在外面。
- 类似于一个人可以在拥有某个储物柜钥匙的同时,再次打开并关闭这个储物柜(递归调用加锁的方法)。在Java中,
-
悲观锁与乐观锁:
- 悲观锁对应现实生活中的保守做法,就像人们总是假设储物柜随时可能被人占用,所以每次都要确认柜门是否已经上锁(即每次访问资源前都进行加锁操作)。
- 乐观锁则相对乐观,如同认为短时间内储物柜不太可能被占用,于是先直接打开柜门查看(不立即加锁),若发现已有物品(即数据已被修改),则重新开始尝试(回滚并重试)。在Java中,乐观锁常用CAS(Compare And Swap)原子指令实现,通过不断地比较并交换内存值来实现无锁并发控制。
-
条件变量与信号量:
- 在Java的
java.util.concurrent.locks.Condition
接口中,条件变量可以看作是储物柜旁边的公告板。假设每个人在等待储物柜清空时,并不一直傻傻地站在那里,而是去公告板那里登记一下,留下联系方式,然后去做其他事情。一旦储物柜空出来了,会有人通知(signal)登记过的下一个人。这就好比条件变量提供的await()方法让线程等待,并通过signal()或signalAll()唤醒等待的线程。
- 在Java的
-
StampedLock:
- 假设储物柜升级为了智能储物柜,不仅有锁,还有印章(stamp)。你可以快速查看印章判断储物柜状态(读模式),如果想存取物品,则需要换领新的印章(写模式或乐观写模式)。StampedLock就是这样一种更精细的锁机制,它可以提供读模式、写模式和乐观读写模式,极大地提高了多线程环境下的读操作并发性能。
-
死锁与活锁:
- 死锁就像浴室里每个人都拿着别人的储物柜钥匙,互相等待对方释放储物柜,结果谁也无法离开。在Java并发编程中,如果不合理地管理和释放锁,就可能出现死锁现象,导致程序无法正常执行。
- 活锁则像是一群人为了避免碰撞而不断调整自己的行进方向,结果却导致彼此都无法前进。在并发编程中,活锁表现为线程因为某种条件反复重试,虽然都在运行并未阻塞,但实际上却没有进展。
也许上边的解释中,有的你能理解,有的理解起来有点困难,没关系,挑自己能理解的多想想就行。
3.锁升级
我们继续,接下来想象一个健身房的更衣柜管理系统:
-
偏向锁(Biased Locking):
- 生活场景:每天你都固定使用同一个更衣柜。一开始,管理员并没有给任何柜子上锁,但当你首次使用时,管理员会在你的柜子上标注“专属”标签,并记录你的会员卡号(线程ID)。之后你每次来健身,只要出示相同的会员卡,管理员就知道这个柜子一直是你在用,无需每次都开锁、关锁,大大提升了效率。
- Java中的偏向锁也是这样,当一个线程首次获取对象锁时,JVM会在对象头存储该线程ID,后续该线程再次进入同步代码块时,只需检查对象头的线程ID是否一致即可,几乎无额外开销。
-
轻量级锁(Lightweight Locking):
- 生活场景:如果有一天,健身房突然来了很多新会员,其中有些人也需要使用你平时习惯的柜子。管理员这时候不能假设柜子一直是你专用,但他仍然尽量减少物理锁的使用。当别人尝试使用你的柜子时,管理员首先会让你继续使用,但会密切关注这个柜子的状态,如果发现有其他人在等待,他会要求你尽快完成并离开,以便其他人使用(自旋等待)。
- 在Java中,当有第二个线程尝试获取已被偏向的锁时,偏向锁会升级为轻量级锁。轻量级锁不会立即阻塞线程,而是通过CAS操作尝试获取锁,若获取失败则通过自旋的方式等待锁释放,这种方式对于线程竞争不激烈的情况能有效降低上下文切换的开销。
-
重量级锁(Heavyweight Locking):
- 生活场景:随着健身房越来越热门,更衣柜的竞争变得异常激烈,连续几次都有不同的人需要使用同一个柜子。管理员不得不采取严格的措施,当柜子被占用时,后面的会员必须排队等待,而且占用者结束使用时需明确通知管理员解锁。
- 在Java中,当自旋多次尝试获取轻量级锁依然失败时,意味着竞争过于激烈,此时JVM会把锁升级为重量级锁,通过操作系统互斥量(mutex)来确保线程间的互斥,这会导致线程阻塞和唤醒,产生较大的性能开销,但能确保在高并发场景下数据的正确性。
参照上述生活中的理解,你能找出普通锁和分布式锁的实例来吗?评论区欢迎留下你的看法!
再回到开头的总结,锁的实现原理都是为了达到一个目的——让所有的线程都能看到某种标记。这个时候,你是否对这句话有了进一步的理解?
授人以鱼,不如授人以渔。
当编程时间久了之后,你会发现,编程中的世界和我们生活中的世界简直一模一样。编程中的一些设计思想和原理跟生活中是相通的,只要你愿意思考,你总能通过编程想到生活中的例子。
编程中的概念我们不能靠死记硬背,一定要理解它,从目的入手,从原理入手,一通百通,多多练习,你会发现自己进步的更快了!
希望本篇文章能对你有所帮助和启发。