盘点 Java 的那些个锁呦!

Posted 流楚丶格念

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了盘点 Java 的那些个锁呦!相关的知识,希望对你有一定的参考价值。

文章目录

Java的那些个锁

个人从几个角度给划开了,我觉得更容易区分,也没找到专业的定论给分类吧,也不找了,累了,就自己分了,便于记忆,别骂我别骂我

情感角度:乐观与悲观

悲观锁

悲观锁就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁

传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。

乐观锁

乐观锁就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制

乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库如果提供类似于write_condition机制的其实都是提供的乐观锁。


Java 中使用的各种锁基本都是悲观锁,那么 Java 中有乐观锁么?

结果是肯定的,在JDK1.5 中新增 java.util.concurrent (J.U.C)就是建立在CAS之上的。CAS就是乐观锁的一种实现算法,相对于 synchronized 这种阻塞算法,CAS是非阻塞算法的一种常见实现。所以J.U.C在性能上有了很大的提

比如 java.util.concurrent.atomic 下面的原子类都是通过乐观锁实现的。

我们看其源码:

public final int getAndAddInt(Object var1, long var2, int var4) 
    int var5;
    do 
        var5 = this.getIntVolatile(var1, var2);
     while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    return var5;

通过上述源码可以发现,在一个循环里面不断 CAS,直到成功为止。

功能角度:独占与共享

排他锁(独占锁)

排他锁又称为写锁、独占锁。获准排他锁后,既能读数据,又能修改数据。我们通常所说的锁,如果没有特别说明,其实指的就是排他锁。

排他锁一次只能被一个线程所持有。比如:线程T1对数据A加上排他锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和 JUC中Lock的实现类就是互斥锁。

共享锁

共享锁又称为读锁,获得共享锁之后,可以查看但无法修改和删除数据。

共享锁可被多个线程所持有。比如:线程T1对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

公正角度:公平与非公平

公平锁:就是比较公平,根据请求锁的顺序排列。也就是先进来得先获取锁,后进来得后获取锁,采用队列进行存放,遵循得原则是先进先出的原则。类似于生活中的排队做某事,比如排队吃饭

非公平锁:非公平锁不是根据请求的顺序序排列,谁能够抢到锁就归谁

比如我们使用ReentrantLock,通过修改构造参数来实现公平与非公平锁:

new ReentrantLock(true) // 公平锁
new ReentrantLock(fasle)// 非公平锁

实现原理

在Java中实现锁的方式有两种,一种是使用Java自带的关键字synchronized对相应的类或者方法以及代码块进行加锁,另一种是ReentrantLock,前者只能是非公平锁,而后者是默认非公平但可实现公平的一把锁。

ReentrantLock在构造的时候通过传入fair的boolean值来创建公平锁与非公平锁

public ReentrantLock(boolean fair) 
    sync = fair ? new FairSync() : new NonfairSync();

我们也能看到,ReentrantLock是基于其内部类FairSync(公平锁)和NonFairSync(非公平锁)实现的,并且它的实现依赖于Java同步器框架AbstractQueuedSynchronizer(AQS),AQS使用一个整形的volatile变量state来维护同步状态,这个volatile变量是实现ReentrantLock的关键。我们来看一下ReentrantLock的类图:

ReentrantLock 的公平锁和非公平锁都委托了 AbstractQueuedSynchronizer#acquire 去请求获取。

各个函数功能说明如下:

  • tryAcquire 是一个抽象方法,是公平与非公平的实现原理所在。

  • addWaiter 是将当前线程结点加入等待队列之中。公平锁在锁释放后会严格按照等到队列去取后续值,而非公平锁在对于新晋线程有很大优势。

  • acquireQueued 在多次循环中尝试获取到锁或者将当前线程阻塞。

  • selfInterrupt 如果线程在阻塞期间发生了中断,调用 Thread.currentThread().interrupt() 中断当

前线程

公平锁和非公平锁在说的获取上都使用到了 volatile 关键字修饰的state字段, 这是保证多线程环境下锁的获取与否的核心。但是当并发情况下多个线程都读取到 state == 0 时,则必须用到CAS技术,一门CPU的原子锁技术,可通过CPU对共享变量加锁的形式,实现数据变更的原子操作。volatile 和 CAS的结合是并发抢占的关键

公平锁FairSync

公平锁的实现机理在于每次有线程来抢占锁的时候,都会检查一遍有没有等待队列,如果有, 当前线程会执行如下步骤:

if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) 
    setExclusiveOwnerThread(current);
    return true;

其中hasQueuedPredecessors是用于检查是否有等待队列的:

public final boolean hasQueuedPredecessors() 
    // The correctness of this depends on head being initialized
    // before tail and on head.next being accurate if the current
    // thread is first in queue.
    Node t = tail; // Read fields in reverse initialization order
    Node h = head;
    Node s;
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());

然后再调用 setExclusiveOwnerThread(current) 设置将当前线程设置为锁对象持有者。

非公平锁NonfairSync

非公平锁在实现的时候多次强调随机抢占

if (compareAndSetState(0, acquires)) 
    setExclusiveOwnerThread(current);
    return true;

与公平锁的区别在于新晋获取锁的进程会有多次机会去抢占锁,被加入了等待队列后则跟公平锁没有区别。

代码示例

ReentrantLock实现非公平/公平锁

如下代码是非公平锁:

package com.yyl.locktest;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class FairAndUnFair implements Runnable 
    private static int count = 0;
    private static Lock lock = new ReentrantLock(false);
    
    @Override
    public void run() 
        while (count < 1000) 
            try 
                lock.lock();
                createCount();
             finally 
                lock.unlock();
            
        
    

    private void createCount() 
        System.out.println(Thread.currentThread().getName() + ",coiunt:" + count);
        count++;
    

    public static void main(String[] args) 
        // 开始时间
        long stratTime = System.currentTimeMillis();
        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 5; i++) 
            Thread  thread = new Thread(new FairAndUnFair());
            thread.start();
            threads.add(thread);
        
        threads.forEach((t)->
            try 
                t.join();
            catch (Exception e)
                e.printStackTrace();
            
        );
        long endTime = System.currentTimeMillis();
        System.out.println(endTime - stratTime);
    

运行结果如下所示:

公平锁实现我们只需要修改下刚才的代码,将false改为true:

private static Lock lock = new ReentrantLock(true);

运行结果如下,非常的银杏:

synchronized实现非公平锁

代码如下

package com.yyl.locktest;

import java.util.ArrayList;
import java.util.List;

public class SynchronizedUnFair implements Runnable 
    private static int count = 0;

    @Override
    public void run() 
        while (count < 100000) 
            createCount();
        
    

    private synchronized void createCount() 
        System.out.println(Thread.currentThread().getName() + ",coiunt:" + count);
        count++;
    
    
    public static void main(String[] args) 
        //开始时间
        long stratTime = System.currentTimeMillis();
        List<Thread> threads = new ArrayList<>();
        for (int i = 0; i < 5; i++) 
            Thread thread = new Thread(new SynchronizedUnFair());
            thread.start();
            threads.add(thread);
        
        threads.forEach((t) -> 
            try 
                t.join();
             catch (Exception e) 
                e.printStackTrace();
            
        );
        long endTime = System.currentTimeMillis();
        System.out.println(endTime - stratTime);
    


结果如下,他是不公平的:

递归角度:可重入与不可重入

可重入锁

可重入锁又名递归锁,指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁。与可重入锁相反,不可重入锁不可递归调用,递归调用就发生死锁。

例如:当同一个线程 T1在外层方法获取对象锁 A 之后,再进入该线程的内层方法会自动获取锁(前提,锁对象是同一个对象),不会因为之前已经获取过还没释放而阻塞。

不可重入锁

不可重入锁只判断这个锁有没有被锁上,只要被锁上,再有申请锁的线程都会被要求等待。


之前我们说过ReentrantLock和synchronized都是重入锁

不可重入锁有啥是我不知道啊,找了半小时了也没找到,爱有没有吧就这样吧。只找到了大家手写进行实现:

不可重入锁手写实现

有一个标志位isLocked判断当前是否有锁,有锁就等待

public class Lock
    private boolean isLocked = false;
    public synchronized void lock() throws InterruptedException
        while(isLocked)    
            wait();
        
        isLocked = true;
    
    public synchronized void unlock()
        isLocked = false;
        notify();
    

应用

public class Count
    Lock lock = new Lock();
    public void print()
        lock.lock();
        doAdd();
        lock.unlock();
    
    public void doAdd()
        lock.lock();
        //do something
        lock.unlock();
    

锁的位置角度:对象、类与方法

对象锁

用于某个对象实例的锁,不同对象实例的对象锁互不干扰。

类锁

用于类的静态方法或者一个类的class对象上的,但是每个类只有一个类锁。实例可以有很多,class对象只有一个。其实类锁只是一个概念上的东西,并不是真实存在的

方法锁

用于修饰方法,执行方法加锁,方法结束解锁

例如 synchronized

  • 在修饰代码块的时候需要一个reference对象作为锁的对象,锁是Synchronized括号里配置的对象。
  • 在修饰方法的时候默认是当前对象作为锁的对象,对于普通方法的同步,锁是当前实例对象。
  • 对于静态方法的同步,锁是当前类的Class对象。
  • 在修饰类时候也是默认是当前类的Class对象作为锁的对象.

按级别(锁的四种状态)

理解这个之前吧,咱先得理解一个问题:锁存哪里了?所以说按照这个分的话,就涉及到锁的灵魂了:

锁是存在哪里的呢?

锁存在Java的对象头中的Mark Work。Mark Work默认不仅存放着锁标志位,还存放对象hashCode等信息。运行时,会根据锁的状态,修改Mark Work的存储内容。如果对象是数组类型,则虚拟机用3个字宽存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,一字宽等于四字节,即32bit。关于对象头等相关知识,可以参考Java虚拟机相关文章。

32位JVM默认状态下Mark Work的存储结构。

32位JVM运行状态下,Mark Work的存储结构。

运行情况下32位JVM的Mark Work

拿synchronized 来说

JDK 1.6之前,synchronized 还是一个重量级锁,是一个效率比较低下的锁。但是在JDK 1.6后,JVM为了提高锁的获取与释放效率对synchronized 进行了优化,引入了偏向锁和轻量级锁 ,从此以后锁的状态就有了四种:无锁、偏向锁、轻量级锁、重量级锁。

并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,这四种锁的级别由低到高依次是:无锁、偏向锁,轻量级锁,重量级锁。

如下图所示:

锁的四个状态级别

1. 无锁

无锁是指没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功

无锁的特点是修改操作会在循环内进行,线程会不断的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。

2. 偏向锁

偏向锁是指当一段同步代码一直被同一个线程所访问时,即不存在多个线程的竞争时,那么该线程在后续访问时便会自动获得锁,从而降低获取锁带来的消耗,即提高性能

初次执行到synchronized代码块的时候,锁对象变成偏向锁通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己(持有锁的线程ID也在对象头里),如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。

如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

当一个线程访问同步代码块并获取锁时,会在标志文档(Mark Word)里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。关于偏向锁的撤销,需要等待全局安全点,即在某个时间点上没有字节码正在执行时,它会先暂停拥有偏向锁的线程,然后判断锁对象是否处于被锁定状态。如果线程不处于活动状态,则将对象头设置成无锁状态,并撤销偏向锁,恢复到无锁(标志位为01)或轻量级锁(标志位为00)的状态。

3. 轻量级锁

轻量级锁是指当锁是偏向锁的时候,却被另外的线程所访问,此时偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,线程不会阻塞,从而提高性能。

轻量级锁的获取主要由两种情况:

  1. 当关闭偏向锁功能时;

  2. 由于多个线程竞争偏向锁导致偏向锁升级为轻量级锁。

一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)

这里要明确一下什么是锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。

在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。

长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)

如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。这是一种折衷的想法,短时间的忙等,换取线程在用户态和内核态之间切换的开销。

4. 重量级锁

重量级锁显然,此忙等是有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)

如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。

重量级锁是指当有一个线程获取锁之后,其余所有等待获取该锁的线程都会处于阻塞状态

简言之,就是所有的控制权都交给了操作系统,由操作系统来负责线程间的调度和线程的状态变更。而这样会出现频繁地对线程运行状态的切换,线程的挂起和唤醒,从而消耗大量的系统资源。

小总结

锁的状态

锁有四种状态:无锁状态、偏向锁、轻量级锁、重量级锁

随着锁的竞争,锁的状态会从偏向锁到轻量级锁,再到重量级锁。而且锁的状态只有升级,没有降级。也就是只有偏向锁->轻量级锁->重量级锁,没有重量级锁->轻量级锁->偏向锁。

锁名称描述应用场景
偏向锁线程在大多数情况下并不存在竞争条件,使用同步会消耗性能,而偏向锁是对锁的优化,可以消除同步,提升性能。当一个线程获得锁,会将对象头的锁标志位设为01,进入偏向模式.偏向锁可以在让一个线程一直持有锁,在其他线程需要竞争锁的时候,再释放锁。只有一个线程进入临界区
轻量级锁当线程A获得偏向锁后,线程B进入竞争状态,需要获得线程A持有的锁,那么线程A撤销偏向锁,进入无锁状态。线程A和线程B交替进入临界区,偏向锁无法满足,膨胀到轻量级锁,锁标志位设为00。多个线程交替进入临界区
重量级锁当多线程交替进入临界区,轻量级锁hold得住。但如果多个线程同时进入临界区,hold不住了,膨胀到重量级锁多个线程同时进入临界区

锁的优缺点

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗。适用于只有一个线程访问同步块场景。
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度。如果始终得不到锁竞争的线程使用自旋会消耗CPU。追求响应时间。同步块执行速度非常快。
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢追求吞吐量。同步块执行速度较长。

锁的实现角度:

实现互斥锁(mutex)

在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构(Block Structured)的同步语法。

synchronized关键字经过Javac编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象
如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference。如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。

自JDK 5起,Java类库中新提供了java.util.concurrent包(J.U.C包),其中的java.util.concurrent.locks.Lock接口便成了Java的另一种全新的互斥同步手段。基于Lock接口,用户能够以非块结构(Non-Block Structured)来实现互斥同步,从而摆脱了语言特性的束缚,改为在类库层面去实现同步,这也为日后扩展出不同调度算法、不同特征、不同性能、不同语义的各种锁提供了广阔的空间。

实现分段锁

在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会减低性能。在锁上发生竞争时将通常导致这两种问题,使用独占锁时保护受限资源的时候,基本上是采用串行方式—-每次只能有一个线程能访问它。所以对于可伸缩性来说最大的威胁就是独占锁。

我们一般有三种方式降低锁的竞争程度:

  1. 减少锁的持有时间;
  2. 降低锁的请求频率;
  3. 使用带有协调机制的独占锁,这些机制允许更高的并发性。

在某些情况下我们可以将锁分解技术进一步扩展为一组独立对象上的锁进行分解,这称为分段锁。其实说的简单一点就是:容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术,首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

如下图,ConcurrentHashMap使用Segment数据结构,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。所以说,ConcurrentHashMap在并发情况下,不仅保证了线程安全,而且提高了性能。

读写锁的实现

与传统锁不同的是读写锁的规则是可以共享读,但只能一个写,总结起来为:读读不互斥、读写互斥、写写互斥,而一般的独占锁是:读读互斥、读写互斥、写写互斥,而场景中往往读远远大于写,读写锁就是为了这种优化而创建出来的一种机制。

注意是读远远大于写,一般情况下独占锁的效率低来源于高并发下对临界区的激烈竞争导致线程上下文切换。因此当并发不是很高的情况下,读写锁由于需要额外维护读锁的状态,可能还不如独占锁的效率高。因此需要根据实际情况选择使用。

在Java中 ReadWriteLock 的主要实现为 ReentrantReadWriteLock ,其提供了以下特性:

  1. 公平性选择:支持公平与非公平(默认)的锁获取方式,吞吐量非公平优先于公平。
  2. 可重入:读线程获取读锁之后可以再次获取读锁,写线程获取写锁之后可以再次获取写锁。
  3. 可降级:写线程获取写锁之后,其还可以再次获取读锁,然后释放掉写锁,那么此时该线程是读锁状态,也就是降级操作。

以上是关于盘点 Java 的那些个锁呦!的主要内容,如果未能解决你的问题,请参考以下文章

条漫:盘点程序员那些神逻辑,看到第三个我笑吐了!

开发工具|盘点 Xshell 的那些奇淫技巧。收藏!

盘点2018年化工行业大事故!回顾那些令人心痛的瞬间......

#yyds干货盘点# Spring核心原理之 IoC容器中那些鲜为人知的细节

今天我们来黑一黑苹果:盘点苹果历史上有那些失败的产品?

盘点Spring/Boot的那些常用扩展点