Java的锁机制

什么是锁?

在并发环境下,经常会遇到两个以上的线程访问同一个共享变量,当同时对共享变量进行读写操作时,就会产生数据不一致的情况。

       随着线程并发技术的发展,在多线程环境中,对线程访问资源的限制也越来越多。为了保证获取资源的有序性和占用性,很多编程语言引入了锁机制,通过一种抽象的“锁”来对资源进行锁定,当一个线程持有“锁”的时候,其他线程必须等待“锁”。

       我认为锁就是用来保障多线程环境中,资源获取的有序性和占用性。

那么Java语言中的锁机制是怎么设计的呢?

在谈锁之前我们需要对Java虚拟机的内层结构做一些简单的介绍。

  1. JVM运行时内层结构主要包含了5个部分:程序计数器(PC寄存器)、JVM栈、Native方法栈、堆、方法区。
  2. 上图中红色区域是各个线程私有的,这个区域中的数据不会出现线程竞争的关系;而蓝色区域中的数据被所有线程共享,其中Java堆中存放的是大量对象,方法区中存放类信息、常量、静态变
    量等数据。当多个线程在竞争其中的一些数据时,可能会发生难以预料的异常情况。在程序开发中,锁的主要应用范围就是在数据共享区域。

在Java中,每个对象都拥有一把锁,这把锁存放在对象头中,记录了当前对象被哪个线程占用。那么对象和对象头的结构分别是什么呢?我们先来谈对象本身的结构,Java对象分为三个部分:

    1. 对象头
    2. 实例数据
    3. 对齐填充字节
  1. 实例数据就是你在初始化对象时设定的属性和状态等内容。
  2. 其中对齐填充字节是为了满足“Java对象大小是8字节的倍数”这一条件而设计的,为对象对齐填充了一些无用字节,大可不必理会。
  3. 相较于实例数据,对象头属于一些额外的存储开销,所以它被设计得极小(一般为232bit或264bit)来提升效率。

对象头是我们需要重点要讲的地方,它存放了一些对象本身的运行时信息。对象头包含了两部分:

    • Mark Word
    • Class Pointer

Class Pointer是一个指针,指向当前对象类型所在方法区中的 Class信息; Mark Word存储了很多当前对象的运行时状态信息,比如 HashCode、锁状态标志、指向锁记录的指针、偏向线程ID、锁标志位等等。

上面我们提到了,对象头被设计得很小, Mark Word则主要体现了这一点,通过这张表我们可以看到,Mark Word只有32bit(或64bit)并且它是非结构化的。这样,在不同的锁标识位下,不同字段可以重用不同的比特位,节省了空间。我们从这张表中能看到,这把抽象的“锁”的信息就存储在对象头的 Mark Word中。我们重点关注最后两位,这两位代表锁标志位,分别对应“无锁”、“偏向锁”、“轻量级锁”、“重量级锁”这四种状态,关于对象锁的这4种状态,我们在后面的锁的再设计和锁优化 章节还会详细说明。

Monitor

在进入到锁分类、定义和应用场景章节之前,我们还需要先介绍一下Monitor:

 Monitor常常被翻译成监视器或管程。简单来说,你可以把它想像成一个只能容纳一名客人房间,而把想要获取对象锁的线程想像成想要进入这个房间的客人。一个线程进入了 Monitor,那么其他线程只能等待,只有当这个线程退出,其他线程才有机会进入。

如上图所示:

  1.  Entry Set中聚集了一些想要进入Monitor的线程,它们都处于waiting状态。
  2. 假设某个名为A线程成功进入了Monitor,那么它就处于active状态。
  3. 此时A线程执行途中,遇到一个判断条件,需要它暂时让出执行权,那么它将进入wait set,状态也被标记为waiting。
  4. 这时entry set中的其他线程就有机会进入Monitor,假设一个线程B成功进入并且顺利完成,那么它可以通过notify的形式来唤醒wait set中的线程A,让线程A再次进入Monitor,执行完成后便退出。

以上就是synchronized关键字所实现的同步机制,但是synchronized可能存在性能问题,因为monitor的下层是依赖于操作系统的Mutex Lock来实现的。 Java线程事实上是对操作系统线程的映射,所以每当挂起或唤醒一个线程都要切换到操作系统的内核态,这个操作是比较重量级的。在某些情况下,甚至切换时间本身就会超出线程执行任务的时间,这样的话,使用synchronized将会对程序的性能产生影响。

但是从Java6开始,synchronized关键字进行了优化,引人入了“偏向锁”、“轻量级锁”的概念。因此对象锁总共有四种状态,从低到高分别是“无锁”、“偏向锁”、“轻量级锁”、“重量级锁”,这就分别对应了Mark Word中锁标记位的四种状态。

锁的分类、定义以及应用场景

根据线程是否锁住同步资源的情况 :

  1. 如果要锁住同步资源则使用悲观锁,不锁住同步资源则使用乐观锁。
  2. 乐观锁适用于写少读多的场景用于提升吞吐量,悲观锁适合写多读少和线程间竞争激烈的场景。
  3. 典型的乐观锁有CAS,synchronized关键字和Lock的实现类都是悲观锁。

  1. 所谓悲观锁就是每次拿数据的时候都会认为有其他线程会修改,所以在读数据的时候都会上锁,其他线程就会阻塞直到拿到锁。举个例子,假设厕所只有一个坑位,悲观锁就是上厕所的时候第一时间会把门反锁上,其他人上厕所就只能在门外等候,这就是阻塞。
  2. 乐观锁就是上厕所的时候开着门(当然现实生活中我们也不会这样去做),所以乐观锁就是每次拿数据的时候都乐观的认为其他线程不会修改数据,因此不会上锁。只有在更新数据的时候去判断之前有没有别的线程更新了这个数据,如果这个数据没有被更新过,当前线程就会将自己修改的数据成功写入;如果这个数据已经被其他线程更新了,当前线程要么报错要么自动重试。
  3. 悲观锁与乐观锁是一种广义上的概念,没有谁优谁劣,乐观锁更适用于写少读多的场景,因为不用上锁和释放锁,省去了锁的开销,从而提升了吞吐量。而悲观锁更适用于写多读少的场景,因为线程之间会竞争激烈,如果使用乐观锁的话,就会导致线程不断的进行重试,这样反而会降低性能。

根据多个线程是否共享一把锁的情况:

  1. 如果在并发情况下,需要多个线程共享一把锁就使用共享锁;如果不能共享一把锁就使用排它锁(或者叫做独占锁、独享锁) 。
  2. ReentrantReadWriteLock里的ReadLock为共享锁,而ReentrantLock、synchronized关键字、JUC包中的Lock实现类以及ReentrantReadWriteLock里的WriteLock都是独占锁。

  1. 共享锁是指锁可以被多个线程持有,如果一个线程对数据加上共享锁以后,那么其它线程只能对数据再加共享锁而不能加独占锁,获得共享锁的线程只能读数据而不能修改数据。
  2. 独占锁是指锁一次只能被一个线程所持有,如果一个线程对数据加上排它锁以后,那么其它线程就不能再对这个数据加上任何类型的锁,获取独占锁之后的线程既能读数据又能修改数据。
  3. 互斥锁锁是独占锁的一种常规实现,它是指同一个资源同时只允许一个访问者对它进行访问,并且具有唯一性和排它性,一次只能一个线程拥有互斥锁,其他线程只能等待。
  4. 读写锁是共享锁的一种具体实现,读写锁是管理一组锁,一个是只读的锁,一个是只写的锁;读锁可以在没有写锁的情况下被多个线程同时持有,而写锁只能是独占的,写锁的优先级要高于读锁,一个获取了读锁的线程必须能够看到前一个释放的写锁所更新的内容;读写锁相比于互斥锁并发程度更高,每次只能写一个线程,但是同时可以有多个线程并发读。
  5. ReentrantReadWriteLock实现了ReadWriteLock接口,ReentrantReadWriteLock支持锁降级不支持锁升级, 可以由写锁降级为读锁。
  6. 在JDK源码中定义了一个读写锁接口:ReadWriteLock。

根据多线程竞争的时候是否要排队的情况 :

  1. 需要排队获取锁的情况使用公平锁,如果是先尝试插队,插队失败后再排队就是非公平锁。

  1. 所谓公平锁是指多个线程按照申请锁的顺序来获取锁,这就类似于排队买票,先来的人会先买,后来的人在队尾排着,这就是公平的。在Java中可以通过ReentranLock构造函数的初始化来实现公平锁 。
  2. 非公平锁是指多个线程获得锁的顺序并不是按照申请锁的顺序来获取的,有可能后申请的线程比先申请的线程优先获取锁;所以在高并发的情况下,有可能造成优先级翻转,或者出现某个线程 一直得不到锁的饥饿状态;
  3. 在Java中synchronized关键字是非公平锁,而ReentrantLock默认也是非公平的,如下图所示:

根据一个线程中的多个流程,是否获得同一把锁的情况 :

  1. 如果一个线程中的多个流程能够获取同一把锁,那就使用可重入锁;如果一个线程中的多个流程不能够获取同一把锁,那就使用不可重入锁。 

  1. 可重入锁也成为递归锁,它是指同一个线程在外层方法获取了锁,在进入内层方法的时候会自动获取锁。比如ReentrantLock,从它的名字就能看出它是一个可重入锁,而Java中的synchronized关键字也是一个可重入锁。使用可重入锁,从一定程度上可以避免死锁。
  2. 不可重入锁:主流没有实现不可重入锁 。

以synchronized为例 ,在上面这段代码中,methodA()调用methodB(),如果一个线程调用methodA()已经获取了锁,那么再去调用methodB()就不用再次获取锁了, 这就是可重入锁的特性。如果使用非重入锁,methodB()就可能不会被当前线程执行,还有可能造成死锁。

根据某个线程锁住同步资源失败,是否不阻塞的情况 :

  1. 如果某个线程锁住同步资源失败,但是希望这个线程不阻塞,就可以使用自旋锁或者自适应自旋锁;如果线程获取锁失败后会阻塞自身,则称为阻塞锁。
  2. 自旋锁有:Java中的atomic类 ,阻塞锁有:ReentrantLock、ReentrantReadWriteLock和synchronized等。

  1. 自旋锁是指线程在没有获得锁的时候,不被直接挂起,而是执行一个循环检查,这个循环检查就是所谓的自旋,自旋锁的目的是为了去减少线程被挂起的几率,因为线程的挂起和唤醒都是比较消耗资源的操作。
  2. 如果锁被一个线程占用的时间比较长的时候,即使使用了自旋,当前线程还是会被挂起。循环检查就变成了浪费资源的操作,反而降低了整体性能,因此自旋锁是不太适合锁占用时间比较长的情况。 
  3. 在Java中,AutomicInteger类就是自旋锁的操作,如以上这段代码中,循环条件是调用了一个compareAndSwapInt()方法,这个方法被称为CAS操作,如果失败就会一直循环获取当前的value值然后重试,这个过程就叫做自旋。
  4. 在JDK1.6之后引入了自适应自旋,这个就比较智能,自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间以及锁拥有者的状态来决定。如果虚拟机认为这次自旋很有可能再次成功,那就会持续较长的时间;如果自旋很少成功,那以后就可能直接省略掉自旋的过程,从而避免浪费CPU的资源。

锁的再设计和锁优化

  1. 这些锁的状态会随着多线程的竞争情况逐渐升级,但是不能降级。
  2. 如果多个线程中,只有一个线程能修改资源成功,其它线程只是重试,而不锁住资源,我们称之为无锁状态,无锁状态其实就是一种乐观锁。
  3. 如果第一个线程访问加锁资源的时候自动获取锁,而不存在多线程竞争的情况,资源就会偏向于第一个访问资源的线程, 而这个线程每次访问都不需要重复获取锁了,这种状态我们称之为偏向锁。偏向锁的实现是通过控制对象的Mark Word标志来实现的,如果当前的锁是可偏向状态, 那就需要进一步判断对象头存储的线程ID是否与当前的线程ID一致,如果一致就允许进入。
  4. 当线程的竞争变得比较激烈的时候,偏向锁就会升级为轻量级锁。轻量级锁认为虽然竞争存在,但是理想情况下的竞争程度很低,所以通过自旋方式等待上一个线程释放锁。
  5. 如果线程的并发进一步加剧,线程的自旋超过了一定的次数,或者一个线程持有锁一个线程在自旋,这个时候如果再有第三个线程访问的时候,轻量级锁就会膨胀成重量级锁。重量级锁会使除了当时拥有锁的线程以外的所有线程都会阻塞,升级成重量级锁以后其实就变成了互斥锁。也就是说一个线程拿到了锁,其它线程都处于阻塞等待状态。

在Java中synchronized关键字内部的设计实现原理就是这么一个锁升级的过程。

锁再设计之分段锁

  1. 分段锁设计的目的是将锁的粒度进一步细化,当操作不需要更新整个数据的时候,就仅仅只是针对数组中的一项进行加锁操作。
  2. 比如说CurrentHashMap的底层原理就使用的是分段锁 Segment,这样的话就能支持多线程并发操作了。

锁粗化

  1. 锁粗化就是将多个同步块的数量减少并且将单个同步块的作用范围扩大,本质上就是将多次上锁和解锁的请求合并为一次同步请求。
  2. 例如,在一个循环体中有一个代码同步块,每次循环都会进行加锁解锁操作,如上述代码所示,经过锁粗化后变成右边的代码 。

锁消除  

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

  1. 如上面中的test()方法,它的主要作用是将字符串s1和字符串s2串联起来,在test()方法中的三个变量:s1、s2和stringBuffer都是局部变量,而局部变量是存在于栈上的,栈又是线程私有的,所以就算有多个线程同时访问test()方法它也是线程安全的。 
  2. 我们都知道StringBuffer是线程安全的类,因为append()方法是同步方法,源码如上图所示。
  3. 由于test()方法本来就是线程安全的,为了提升效率,Java虚拟机就会自动帮我们消除这些同步锁,这个过程就称之为:锁消除。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值