Java"锁",你好

前言

Java中的锁种类丰富,一方面在某些场景使用对的锁,会有非常高的效率。但是另一方面,由于锁的复杂性,也给开发人员带来了使用上的困难。

因此,希望能通过一篇文章总结下锁的知识点。

目录

  • 悲观锁和乐观锁
  • 自旋锁、适应性自旋锁
  • 无锁、偏向锁、轻量级锁和重量级锁
  • 公平锁和非公平锁
  • 可重入锁和非重入锁
  • 共享锁和排它锁

悲观锁和乐观锁

悲观锁和乐观锁的概念比较容易理解。举个例子来帮助理解下:

程序猿小A和程序猿小B同时看上了妹子小M,这时候小A和小B就会出现竞争了。小A是个悲观者,认为自己在追妹子的时候,一定会有别的汉子来偷腥,不用说了,直接在小M家门口挂个牌子,此妹子我追了,你们这群汉子给我滚。对于小M(数据),小A提前上了个保障,这种情况我们就会认为这是一种悲观锁。而小B就比较乐观了,他认为小M哪有那么多人追,等有人追了自己到时再来处理。这就是乐观锁了。

对于同一个数据的并发操作,悲观锁认为自己在操作数据过程中,一定会有别的线程来进行数据操作,为确保避免这种情况的出现,在获取数据的时候回提前加锁。乐观锁则不同。它的触发时机并不在于操作数据过程,而在于更新数据的时候回判断是否有数据变更,如果有,再进行相关处理。

那么在什么场景去使用悲观锁,什么场景去使用乐观锁呢?

从上面的描述,我们可以这么认为:

  • 悲观锁:往往在写操作多的场景,会更加适合;
  • 乐观锁:在读多写少的场景,较悲观锁来说,性能会大幅提升;

在Java中,synchronized关键字和Lock的实现类都是悲观锁,而Java的原子类递增操作则是乐观锁。

以下举例说明下:

//悲观锁:synchronized
public synchronized void testSyn(){
       //dosomething
}

//悲观锁:ReentrantLock

private ReentrantLock lock =new ReentrantLock();
public void testReentranLock(){
     lock.lock();
     //do something
     lock.unlock();
}
//乐观锁举例
private AtomicInteger atomicInt=new AtomicInteger();

private void testAtomicInteger(){
      atomicInteger.incrementAndGet();
}

乐观锁与CAS

提到乐观锁,不得不提到它的主要实现方式CAS。
这里我们仍然以AtomicInteger的实现方式来尝试了解CAS的技术原理。

举例前,可以先简单了解下CAS。

CAS(Compare And SWAP),可以参考这篇文章
https://www.cnblogs.com/Mainz/p/3546347.html

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;
   
    // setup to use Unsafe.compareAndSwapInt for updates
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

   private volatile int value;
   
   public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
   
 /**
     *比较位移处的内存位置的值和期望的值,如果相同则更新,此更新为原子化更新
     * @param expect the expected value
     * @param update the new value
     * @return {@code true} if successful. False return indicates that
     * the actual value was not equal to the expected value.
     */
    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }

}

简单说明一下:
unsafe
Java本身不能直接访问操作系统底层,而是通过本地方法访问。Unsafe提供硬件级别的原子操作。
通过Unsafe类,我们可以:
(1)分配内存,释放内存
(2)定位对象某字段的内存位置,也可修改对象的字段值,即使它是私有的
(3)挂起和恢复
(4)CAS操作。
由于Unsafe不是本文的重点,更详细的说明可以参考:
https://www.cnblogs.com/thomas12112406/p/6510787.html

valueOffset
偏移量

value
volatile关键字,保证线程间可见。

乐观锁的实际应用可以见:https://blog.csdn.net/wueryan/article/details/85239700

自旋锁、适应性自旋锁

在讲自旋锁前,举个栗子来帮助理解下。

程序猿小A约妹子去看电影,但妹子小M说稍微等她十分钟,她还在家里换衣服。这时候小A有两个选项:选项一,在妹子楼下继续等妹子换好衣服下楼;选项二,趁着妹子换衣服的时候,赶回家码多两行代码。

作为一名高级程序猿,你会帮小A怎么选择?
如果妹子换衣服的时间比较久,当然可以悠哉悠哉的回家继续码代码了,但是如果妹子换衣服时间真的在十分钟,那么前脚刚赶回家,后脚就收到十分钟给她出现在她面前否则分手的短信,未免就得不偿失了。

自旋锁与此类似。当一个Java线程阻塞或者被唤醒,是需要耗费处理器时间的,但是有时候同步块的内容耗费时间太多,反而切换状态的时间会更长些,对于这种线程挂起和现场恢复的花费,实在是伤不起。

就上面的例子,如果妹子小M很快换好衣服(锁被释放了),对于小A来说,当然他不用专门跑回家,省了这部分花费(节省切换线程开销),直接抱得美人归。

这也就是自旋锁的由来。凭我们的人生经验看,自旋锁自然是有利有弊的。如果锁被占用的时间太长,那么自旋的线程所耗费的等待就得不偿失。

自旋的次数可以通过-XX:PreBlockSpin来更改。默认10次,没有成功则直接挂起。

自适应的自旋锁无非就是自旋锁的升级版。还是刚那个例子

小A经过几次约小M去看电影,每次都要等小M几个小时换衣服,都总结了人生经验了,这时候再遇到他约电影说换衣服,那么自然的就直接回家码代码了。但是如果上两次小M就在十分钟内出现,那么小A自然也就不会滚回家写代码了。

自适应的自旋锁较普通的自旋锁多了一层动态的自旋时间的调配。
自适应的自旋锁自旋的次数取决于上一次在同一个锁的自旋时间和锁的持有者的状态来决定的。它是拿上一次的自旋时间以及运行状态来作为参考依据,如果上一次自旋等待成功,ok,那么对于虚拟机来说,这一次成功的概率也比较大,那么允许他自旋等待更长的时间。如果相反,屡次自旋等待后,却失败了,那么下次进来后就直接阻塞进程,避免浪费处理器资源。

无锁、偏向锁、轻量级锁和重量级锁

这四种锁是指锁的状态,更多的是针对synchronized来说的。
在Java中,每一个Java对象都有自己的内部锁或者Monitor锁。
可以通过下图大致了解下Java对象与Monitor锁的关系。

《Java

在不同的锁状态下,存储的数据是不同的,具体如下:

锁状态存储内容偏向锁标识位锁标识位
无锁哈希码、分代年龄001
偏向锁线程ID、时间戳、分代年龄101
轻量级锁指向栈中锁记录的指针ptr00
重量级锁指向monitor的指针ptr10

简单说明一下,在Java对象中对象头包括两部分数据:Mark Word,Class Pointer。而Mark Word存储了Monitor的引用,此时对象和Monitor产生关联。每一个线程都有一个可用的Monitor列表,如果对象被锁上了,这时候对象便和Monitor进行关联,monitor的owner字段便存放该锁的线程的唯一标识,声明占用。

Monitor的实现依赖底层操作系统的互斥锁(Mutex Lock)来实现。
但是过于依赖操作系统来实现锁,其实对于程序来说,这太损耗性能了。这种依赖操作系统Mutex Lock实现的锁,就是重量级锁。

基于这种原因,我们将锁划分成4个粒度:无锁、偏向锁、轻量级锁、重量级锁。

无锁

无锁,其实就是CAS原理的应用。所有线程都能访问同一个资源,但同时只有一个线程能修改成功。

偏向锁

了解偏向锁,我们需要理解引入偏向锁的意义。
当我们在进行多线程并发操作时,会经常有尝试去获取以及释放锁,这里有多次CAS指令的性能损耗。但是我们想象下,在没有多线程竞争的情况,进入同步块,如果仍存在获取锁以及释放锁的CAS指令损耗,其实是对性能的一种浪费。那么偏向锁的引入就是为了在只有一个线程的情况下执行同步块进一步提升性能。

当一个线程访问同步代码块并获取锁时,会在Mark Word里存储锁偏向线程ID。只有在更新这里的线程ID时,会有一次CAS原子指令。

轻量级锁

轻量级锁实际是偏向锁的升级。前面说过,当锁是偏向锁时,此时被别的线程访问,此时偏向锁就会升级成轻量级锁,其他线程会通过自旋的形式去获取锁。

重量级锁

重量级锁是轻量级锁的一个升级。如果当前只有一个等待线程,则该线程通过自旋进行等待,但是超过了自旋的次数,或者说此时已经有一个线程持有锁,一个在自旋,这时候有第三个来访,此时就会触发升级。

公平锁、非公平锁

公平锁和非公平锁比较好理解。

公平锁,顾名思义,追求的是一种公平,怎么样才能做到公平呢?不管三七二十一,多个线程只要按照申请锁的顺序来获取锁就可以了,先到先得。但是这里有个问题,由于每次释放锁后都要尝试去环形阻塞线程,此时开销会比较大。

非公平锁,则是我也不管三七二十一了,我先去申请获取锁,这时候如果我运气好,刚好获取到了,那么这个线程就直接获取到锁。这样就少了对线程环形的开销。

可以通过ReentrantLock来研究公平锁和非公平锁。

《Java

《Java

可重入锁、非可重入锁

可重入锁和非可重入锁的一个区分就是在同一个线程的不同步骤里究竟能不能去获取同一个锁,如果能,那么就是可重入锁,如果不能,就是非可重入锁。在Java中ReentrantLock和synchronized都是可重入锁。对比非可重入锁,可重入锁不容易带来死锁。

举个简单例子:

public class Test {
          public synchronized void doSomething(){
                System.out.println("do something……");
                doOtherThing();
         }
        
        public synchronized void doOtherThing(){
                System.out.println("do other thing……");
       }
}

由于在第一次进入doSomething的时候,此时其实获取了内置锁,这时候在doOtherThing()方法时,又进行synchronized进行修饰,但是由于synchronized是可重入锁,这时候在同一个线程,其实是直接获取当前对象的锁的。然而假设synchronized不是可重入锁,是一个非可重入锁,这时候会发生什么事情呢?
非可重入锁的情况下,由于doOtherThing()这个方法会再次去获取这个对象锁,此时他会做出等待这个对象锁的释放,但是由于doOtherThing是doSomething方法的一个步骤,因为doOtherThing么以偶执行完,doSomething也一样在等待未执行完状态,此时两边互相等待,造成了死锁。

了解了一些基本原理,我们需要了解一下可重入锁以及非可重入锁的基本实现。直接给出源码:
ReentrantLock
《Java

NonReentrantLock
《Java

从可重入锁的实现来看,我们看到源码有一个status的状态来作为计数,初始是0,如果在同一个线程获取到一个锁了,status则会加上获取的锁次数,则当前锁会被同一个线程的其他执行方法或者对象获取到。下面是非可重入锁,他就比较简单,如果status不是0,则直接返回false,说明这锁被别人占用了,哪怕是同一个线程的,他本身也是不认的。

排他锁、共享锁

排他锁,见文识意,就是一旦这个锁被其他线程所持有了,就不能被其他线程去持有,而共享锁,则是允许其他线程能去持有该锁。

未完,待续

    原文作者:java锁
    原文地址: https://blog.csdn.net/wueryan/article/details/84865978
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞