原来java有这么多把锁,图解java中的17把锁

发布时间:2022-03-01 11:16:54 作者:yexindonglai@163.com 阅读(436)

  • 乐观锁和悲观锁
  • 独占锁和共享锁
  • 互斥锁和读写锁
  • 公平锁和非公平锁
  • 可重入锁
  • 自旋锁
  • 分段锁
  • 锁升级(无锁|偏向锁|轻量级锁|重量级锁)
  • 锁优化技术(锁粗化、锁消除)

1、悲观锁

       悲观锁对应于生活中悲观的人,悲观的人总是想着事情往坏的方向发展。

       举个生活中的例子,假设厕所只有一个坑位了,小明上厕所会第一时间把门反锁上,这样其他人上厕所只能在门外等候,这种状态就是「阻塞」了

        在数据中因为总是假设最坏的情况,所以每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁,有任何一线程对数据进行读写操作都会上锁,数据库的行锁、表锁、读锁,写锁都是悲观锁,Java中synchronized锁和ReentrantLock 就是悲观锁思想的实现。

关于ReentrantLock锁,具体的实现原理请看我的另一篇文章:ReentrantLock底层原理、手写Lock锁

2、乐观锁 

        乐观锁 对应于生活中乐观的人,乐观的人总是想着事情往好的方向发展。

        乐观锁总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,有任何一线程对数据进行写操作都会上锁;也就是读的时候不上锁,写的时候才上锁,

    乐观锁一般用cas和版本号机制实现,如果想要了解具体原理请看我的另一篇文章 : CAS 自旋锁/无锁机制算法

    java.util.concurrent.atomic 包下的原子类就是使用CAS 乐观锁实现的

乐观和悲观两种锁的使用场景

悲观锁和乐观锁没有孰优孰劣,有其各自适应的场景。

乐观锁适用于写比较少(冲突比较小)的场景,因为不用上锁、释放锁,省去了锁的开销,从而提升了吞吐量。

如果是写多读少的场景,即冲突比较严重,线程间竞争激励,使用乐观锁就是导致线程不断进行重试,这样可能还降低了性能,这种场景下使用悲观锁就比较合适。

3、独占锁

独占锁是指锁一次只能被一个线程所持有。如果一个线程对数据加上排他锁后,那么其他线程不能再对该数据加任何类型的锁。获得独占锁的线程即能读数据又能修改数据。

JDK中的synchronized和java.util.concurrent(JUC)包中Lock的实现类就是独占锁

悲观锁和独占锁的区别

    悲观锁是针对读写上锁,而独占锁是针对线程的独占,这一点需要区分,但其本质是一样的,有人说独占锁就是悲观锁,这样讲也可以;

4、共享锁

共享锁是指锁可被多个线程所持有。如果一个线程对数据加上共享锁后,那么其他线程只能对数据再加共享锁,不能加独占锁。获得共享锁的线程只能读数据,不能修改数据。

    举个现实中的例子,就拿小区楼房里面的电梯来说,电梯是大家共享使用的,任何一个人都不能把电梯据为己有,更不能对电梯做改装操作,大家都只有使用权,而没有改装权

在 JDK 中 ReentrantReadWriteLock 就是一种共享锁。

5、互斥锁

互斥锁是独占锁的一种常规实现,是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。

互斥锁一次只能有一个线程拥有互斥锁,其他线程只能等待,和悲观锁和独占锁类似

6、读写锁

为了保证多个线程同时访问一个资源进行读写操作时不会出现脏读的情况,有必要使用读写锁进行限制,多个线程同时读一个资源时没有任何问题,但是读写同时进行时就会有问题,写写操作也会有问题,为了保证线程安全的情况下,就需要一个读写锁来解决这个问题;

接下来我们用2个线程模拟四种情况,让我们看看底层有哪些变化,其中AB代表2个不同的线程去读取同一个资源;
  1、两个线程都读取数据 :可同时进行,互不影响
  2、先读后写:写锁等待:读完后才可以写
  3、先写后读 : 读锁等待:写完后才可以读
  4、两个线程都写 :第二个写锁等待:第一个写锁写完后第二个写锁才可以写

在 JDK 中定义了一个读写锁的接口:ReadWriteLock,而ReentrantReadWriteLock 实现了ReadWriteLock接口;

7、公平锁

公平锁指的是多个线程按照指定的顺序执行,整个过程有序地进行每个线程,就像银行排队一样,需要办理业务的人们排成一排,有序地进行办理业务,上一个人业务办理完成后进行下一个人的业务办理!

优点:所有的线程都能得到资源,不会饿死在队列中。

缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大

在 java 中可以通过构造函数初始化公平锁

  1. /**
  2. * 创建一个可重入锁,true 表示公平锁,false 表示非公平锁。默认非公平锁
  3. */
  4. Lock lock = new ReentrantLock(true);

8、非公平锁

非公平锁顾名思义,就是不公平的,多个线程通过抢占的方式进行执行,谁抢到就给谁执行,就跟强盗逻辑是一样的,谁抢到就是谁的;一旦有一个线程抢到锁资源之后,其他的线程只能进入阻塞状态,等待下一轮的抢占;

优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。

缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死!

在 java 中 synchronized 关键字是非公平锁,ReentrantLock默认也是非公平锁。

9、可重入锁/递归锁

重入锁指的是在同一线程,在外层函数获得锁之后,内层递归函数仍然有获得锁的代码,但执行时不受影响,比如A方法加锁了,B方法也加锁了,A调用B方法,表面上看是有2个锁,但实际上他们用的都是同一把锁!

synchronized 和 ReentrantLock 都拥有可重入锁的特性,它们都是可重入锁

10、自旋锁

自旋锁是指线程在没有获得锁时不是被直接挂起,而是执行一个忙循环,这个忙循环会一直判断是否有可用的锁资源,如果有可用的锁资源,就会通过比较和交换(CAS)的方式去抢占资源,如果抢不到则进入下一个忙循环,一直到抢到锁资源为止,这就是所谓的自旋。

自旋锁的目的是为了减少线程被挂起的几率,因为线程的挂起和唤醒也都是耗资源的操作。

如果锁被另一个线程占用的时间比较长,即使自旋了之后当前线程还是会被挂起,忙循环就会变成浪费系统资源的操作,反而降低了整体性能。因此自旋锁是不适应锁占用时间长的并发情况的。

在 Java 1.5之后的并发包中,Atomic开头的类都具有自旋的操作,底层是用CAS(比较和交换)实现的

自适应自旋

CAS 操作如果失败就会一直循环获取当前 value 值然后重试。本质上自旋也是一种开销,所以在JDK1.6又引入了自适应自旋,这个就比较智能了,自旋时间不再固定,由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。如果虚拟机认为这次自旋也很有可能再次成功那就会次序较多的时间,如果自旋很少成功,那以后可能就直接省略掉自旋过程,避免浪费处理器资源。

11、分段锁

分段锁设计目的是将锁的粒度进一步细化,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作

在 Java 语言中 CurrentHashMap 底层就用了分段锁,使用Segment,就可以进行并发使用了。

12、锁升级(无锁|偏向锁|轻量级锁|重量级锁)

    在jdk1.6之前,synchronized 是一把很重的锁,每次加锁的时候都会向操作系统申请锁资源,但是有时候我们在只有一个线程的情况下并不需要这么重的锁,因此每次都用这么重的锁会带来很大的消耗;所以在jdk1.6开始,为了提升性能减少获得锁和释放锁所带来的消耗,引入了4种锁的状态:无锁、偏向锁、轻量级锁和重量级锁,它会随着多线程的竞争情况逐渐升级,但不能降级。

关于锁升级的所有原理请看我另一篇文章里有详细介绍,这里不过多赘述:synchronized 锁升级过程

锁优化技术(锁粗化、锁消除)

13、锁粗化

锁粗化就是将多个同步块的数量减少,并将单个同步块的作用范围扩大,本质上就是将多次上锁、解锁的请求合并为一次同步请求。

举个例子,一个循环体中有一个代码同步块,每次循环都会执行加锁解锁操作

  1. private static final Object LOCK = new Object();
  2. for(int i = 0;i < 100; i++) {
  3. synchronized(LOCK){
  4. // do some magic things
  5. }
  6. }

经过锁粗化后就变成下面这个样子了:

  1. synchronized(LOCK){
  2. for(int i = 0;i < 100; i++) {
  3. // do some magic things
  4. }
  5. }

14、锁消除

锁消除是指虚拟机编译器在运行时检测到了共享数据没有竞争的锁,从而将这些锁进行消除。

举个例子让大家更好理解。

  1. public String test(String s1, String s2){
  2. StringBuffer stringBuffer = new StringBuffer();
  3. stringBuffer.append(s1);
  4. stringBuffer.append(s2);
  5. return stringBuffer.toString();
  6. }

上面代码中有一个 test 方法,主要作用是将字符串 s1 和字符串 s2 串联起来。

test 方法中三个变量s1, s2, stringBuffer, 它们都是局部变量,局部变量是在栈上的,栈是线程私有的,所以就算有多个线程访问 test 方法也是线程安全的。

我们都知道 StringBuffer 是线程安全的类,append 方法是同步方法,但是 test 方法本来就是线程安全的,为了提升效率,虚拟机帮我们消除了这些同步锁,这个过程就被称为锁消除。

StringBuffer.class,消除后的append方法如下,去掉了 synchronized 关键字;

  1. // append 是同步方法
  2. public StringBuffer append(String str) {
  3. toStringCache = null;
  4. super.append(str);
  5. return this;
  6. }

 一张图总结

目前位置,java中所有的锁都在这里面了!

关键字Java