【老生常谈】一文理解Java中的各种锁

在这里插入图片描述

引言

  在多线程环境下,由于多个线程可以同时访问和修改共享资源,如果没有采取相应的措施来保护共享资源,就可能会出现数据竞争、死锁、活锁等问题,导致程序出现不稳定或不可预期的结果或错误,这些称为"线程安全"问题。
  为了解决多线程环境下的安全问题,Java 提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。虽然很多时候用到锁的机会不大,但是锁的问题在面试中经常会遇到,特别是互联网公司,面对很多高并发的时候,抠细节就成了日常。故此,本人整理了目前JAVA里面的锁,用最通俗易懂的话让大家快速记忆。

一、锁概念

  在Java中,锁是一种同步机制,用于控制多个线程对共享资源的访问。锁可以防止多个线程同时对同一个共享资源进行写操作,从而避免数据的不一致性和错误。锁是一种互斥工具,它能够确保同一时间只有一个线程可以访问共享资源,可以让多个线程按照特定的顺序访问共享资源,从而避免死锁、竞争条件等并发问题。
  在Java中,常用的锁有synchronized关键字、ReentrantLock、ReadWriteLock、Semaphore等,这些锁提供了不同的功能和性能特征。

二、锁种类

在Java中,锁分为以下几种类型:

Java锁体系
乐观
悲观
公平
非公平
独占
共享
读写
互斥
自旋
可重入
重量级
轻量级
偏向
分段
同步
序号锁名称应用
1乐观锁(Optimistic Locking)CAS
2悲观锁(Pessimistic Locking)synchronized、vector、hashtable
3公平锁(FairLock)Reentrantlock(true)
4非公平锁synchronized、reentrantlock(false)
5独占锁synchronized、vector、hashtable、ReentrantReadWriteLock中写锁
6共享锁ReentrantReadWriteLock中读锁
7读写锁(ReadWriteLock)ReentrantReadWriteLock,CopyOnWriteArrayList、CopyOnWriteArraySet
8互斥锁(Mutex)synchronized
9自旋锁CAS
10重入锁(ReentrantLock)synchronized、Reentrantlock、Lock
11重量级锁synchronized
12轻量级锁锁优化技术
13偏向锁(Biased Locking)锁优化技术
14分段锁concurrentHashMap
15同步锁synchronized

01、乐观锁

  乐观锁是一种乐观思想,永远处于乐观积极状态,总认为自己在使用数据时不会有别的线程修改数据,所以不会添加锁,只是在更新数据的时候去检查是否发生了冲突。如果检测到冲突,则通过某种方式通知线程重新获取资源并重试更新操作。
在这里插入图片描述

  假定当前环境是读多写少,乐观锁觉得并发操作期间是不会出问题的,读数据时认为别的线程不会正在进行修改(所以没有上锁)。写数据时,判断当前与期望值是否相同,如果相同则进行更新(更新期间加锁,保证是原子性的)。如下图所示,可以同时进行读操作,读的时候其他线程不能进行写操作。

共享
共享
共享
.独占
资源
读操作
读操作
读操作
写操作

  乐观锁的优点是并发性能较高,因为不需要长时间锁定资源。但缺点是可能会引发较多重试操作,增加了系统的开销。

02、悲观锁

  悲观锁是一种悲观思想,它总认为最坏的情况可能会出现。对于同一个数据的并发操作,悲观锁认为自己在使用数据的时候一定有别的线程来修改数据,因此在获取数据的时候会先加锁,确保数据不会被别的线程修改。
在这里插入图片描述

  假定当前环境是写多读少,遇到并发写的可能性高,每次去拿数据的时候都认为其他线程会修改,所以每次读写数据都会认为其他线程会修改,所以每次读写数据时都会上锁。其他线程想要读写这个数据时,会被这个线程block,直到这个线程释放锁然后其他线程获取到锁。如上图所示,只能有一个线程进行读操作或者写操作,其他线程的读写操作均不能进行。

独占
共享
共享
.独占
资源
写操作
读操作
读操作
写操作

  悲观锁的优点是能够避免多线程并发访问导致的冲突,保证数据的一致性。但缺点是可能会引发死锁和性能问题,因为长时间锁定资源会降低系统的并发性能。

03、公平锁

  公平锁是一种思想,是指多个线程按照申请锁的顺序来获取锁。在并发环境中,多个线程需要对同一资源进行访问,同一时刻只能有一个线程能够获取到锁并进行资源访问,那么剩下的这些线程怎么办呢?这就好比食堂排队打饭的模型,最先到达食堂的人拥有最先买饭的权利,那么剩下的人就需要在第一个人后面排队,这是理想的情况,即每个人都能够买上饭。对于正常排队的人来说,没有人插队,每个人都在等待排队打饭的机会,那么这种方式对每个人来说都是公平的,先来后到嘛,这种锁也叫做公平锁。

在这里插入图片描述

  那么我们根据上面的描述可以得出:公平锁表示线程获取锁的顺序是按照线程加锁的顺序来分配的,即先来先得的FIFO先进先出顺序。

04、非公平锁

  非公平锁是一种思想。当多个线程加锁时直接尝试获取锁,如果获取不到,则会到等待队列的队尾等待;但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁。多个线程获取锁的顺序,不是按照先到先得的顺序,有可能后申请锁的线程比先申请的线程优先获取锁。
  这就好比食堂排队打饭的模型,最先到达食堂的人拥有最先买饭的权利,那么剩下的人就需要在第一个人后面排队,这是理想的情况,即每个人都能够买上饭。那么现实情况是,在你排队的过程中,就有个别不老实的人想走捷径,插队打饭,如果插队的这个人后面没有人制止他这种行为,他就能够顺利买上饭,如果有人制止,他就也得去队伍后面排队。
在这里插入图片描述
在这里插入图片描述

  那么我们根据上面的描述可以得出:非公平锁就是一种获取锁的抢占机制,是随机获得锁的,和公平锁不一样的就是先来的不一定先得到锁,这个方式可能造成某些线程一直拿不到锁,结果也就是不公平的了。
  在 Java 中 synchronized 是非公平锁,ReentrantLock 可以是非公平锁,也可以是公平锁,默认非公平锁。

//此处创建一个非公平锁,默认就是非公平,true 表示公平,false 表示非公平。
Lock lock =new ReentrantLock(flase);

05、独占锁

  独占锁是一种思想,有时也叫排他锁,是指该锁在同一时刻只能有一个线程获取锁,以独占的方式持有锁。其他线程想要访问资源,就会被阻塞。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。
在这里插入图片描述

  独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。Java中的ReentrantLock 就是以独占方式实现的互斥锁。

06、共享锁

  共享锁是一种思想。可以有多个线程获取读锁,以共享的方式持有锁,本质上与乐观锁、读写锁一样。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁,如下图所示。获得共享锁的线程只能读数据,不能修改数据
在这里插入图片描述

  共享锁则是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源。Java 的并发包中提供了 ReadWriteLock,它允许一个资源可以被多个读操作访问,或者被一个写操作访问,但两者不能同时进行。

07、读写锁

  读写锁是一种技术,通过 ReentrantReadWriteLock 类来实现。为了提高性能, Java 提供了读写锁,在读的地方使用读锁,在写的地方使用写锁。其中,读锁允许多个线程获取读锁,同时访问同一个资源;而写锁只允许一个线程获取写锁,不允许同时访问同一个资源。如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。
  在 Java 中, ReadWriteLock 接口只规定了两个方法,一个返回读锁,一个返回写锁。

public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}

  JDK 内部提供了一个唯一一个 ReadWriteLock 接口实现类是 ReentrantReadWriteLock。通过名字可以看到该锁提供了读写锁,并且也是
可重入锁。

public class ReadWriteLockDemo {
    // 创建一个读写锁。它是一个读写融为一体的锁,在使用的时候,需要转换
    private final ReadWriteLock lock = new ReentrantReadWriteLock();

    public void read() {
        // 获取读锁
        lock.readLock().lock();
        try {
            // 这里是被读锁保护的代码块
            // 可以允许多个线程同时读取该代码块
            // ...
        } finally {
            // 释放读锁
            lock.readLock().unlock();
        }
    }

    public void write() {
        // 获取写锁
        lock.writeLock().lock();
        try {
            // 这里是被写锁保护的代码块
            // 只允许一个线程写入该代码块
            // ...
        } finally {
            // 释放写锁
            lock.writeLock().unlock();
        }
    }
}

  在上面的代码中,我们使用了一个 ReadWriteLock 对象来保护一个代码块。在 read() 方法中,我们首先调用 readLock() 方法来获取读锁,允许多个线程同时读取被锁保护的代码块,最后在 finally 块中调用 unlock() 方法来释放读锁。在 write() 方法中,我们使用 writeLock() 方法来获取写锁,只允许一个线程写入被锁保护的代码块,最后同样需要在 finally 块中调用 unlock() 方法来释放写锁。
  读写锁可以提高读操作的并发性能,从而提高程序的效率,适用于读多写少的场景。但是需要注意的是,在使用读写锁时,需要考虑锁的粒度和性能问题,避免因为锁的过多或者过少导致程序的性能下降或者数据不一致。

08、互斥锁

  互斥锁与悲观锁、独占锁同义,表示某个资源只能被一个线程访问,其他线程不能访问。加锁后,任何其他试图再次加锁的线程会被阻塞,直到当前进程解锁。其主要有读-读互斥、读-写互斥、写-读互斥、写-写互斥等几种锁。在 Java 中, ReentrantLock、synchronized 锁都是互斥锁。

09、自旋锁

  自旋锁是一种技术。为了让线程等待,我们只须让线程执行一个忙循环(自旋)。由于系统中某些资源的有限性,有时需要互斥访问,只有获取了锁的线程才能够对资源进行访问。所以同一时刻只能有一个线程获取到锁,让后面请求锁的那个线程“稍等一会”。当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。这种采用循环加锁就是自旋锁。

独占
共享
资源
写操作
读操作

  自旋锁的原理比较简单,如果持有锁的线程能在短时间内释放锁资源,那么那些等待竞争锁的线程就不需要进入阻塞状态,它们只需要等一等(自旋),等到持有锁的线程释放锁之后即可获取,这样就避免了用户进程和内核切换的消耗。
  自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗。但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候有大量线程在竞争一个锁,会导致获取锁的时间很长,这种情况下我们要关闭自旋锁。
  下面我们用Java 代码来实现一个简单的自旋锁

public class SpinLockTest {
    private AtomicBoolean available = new AtomicBoolean(false);

    public void lock() {
        // 循环检测尝试获取锁
        while (!tryLock()) {
            // doSomething...
        }
    }

    public boolean tryLock() {
        // 尝试获取锁,成功返回true,失败返回false
        return available.compareAndSet(false, true);
    }

    public void unLock() {
        if (!available.compareAndSet(true, false)) {
            throw new RuntimeException("释放锁失败");
        }
    }
}

  这种简单的自旋锁有一个问题:无法保证多线程竞争的公平性。对于上面的 SpinlockTest,当多个线程想要获取锁时,谁最先将available设为false谁就能最先获得锁,这可能会造成某些线程一直都未获取到锁造成线程饥饿。就像我们下班后蜂拥地挤向地铁,通常我们会采取排队的方式解决这样的问题,

10、重入锁

  可重入锁是一种技术,又称为递归锁,是指任意线程在获取到锁之后,能够再次获取该锁而不会被锁所阻塞(前提锁对象得是同一个对象)。Java 中 ReentrantLocksynchronized 都是可重入锁,可重入锁的一个优点是在一定程度上可以避免死锁。
  我们先来看一段代码来说明一下 synchronized 的可重入性,代码如下所示:

private synchronized void doSomething() {
    System.out.println("doSomething...");
    doSomethingElse();
}

private synchronized void doSomethingElse() {
    System.out.println("doSomethingElse...");
}

  在上面这段代码中,我们对 doSomething()doSomethingElse() 分别使用了 synchronized 进行锁定,doSomething() 方法中调用了 doSomethingElse() 方法,因为 synchronized 是可重入锁,所以同一个线程在调用 doSomething() 方法时,也能够进入 doSomethingElse() 方法中。
  Java中的ReentrantLock类属于可重入锁独占锁,它提供了与synchronized关键字类似的功能,但具有更高的灵活性和可配置性。使用ReentrantLock时,需要先实例化一个ReentrantLock对象,然后使用lock()unlock()方法来获取和释放锁。

public class ReentrantLockExample {
    private ReentrantLock lock = new ReentrantLock();
    private int count = 0;

    public void increment() {
        lock.lock();  // 获取锁
        try {
            count++;
            System.out.println("Count after increment: " + count);
        } finally {
            lock.unlock();  // 释放锁
        }
    }

    public static void main(String[] args) {
        ReentrantLockExample example = new ReentrantLockExample();
        new Thread(() -> example.increment()).start();
        new Thread(() -> example.increment()).start();
    }
}

11、重量级锁

  重量级锁是一种称谓。synchronized 是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本身依赖底层的操作系统的 Mutex Lock来实现。操作系统实现线程的切换需要从用户态切换到核心态,成本非常高,这种依赖于操作系统 Mutex Lock来实现的锁称为重量级锁。为了优化synchonized,引入了轻量级锁、偏向锁。

操作系统
synchronized
Mutex Lock
Monitor Lock

12、轻量级锁

  轻量级锁是JDK6时加入的一种锁优化机制,是相对于使用操作系统互斥量来实现的重量级锁而言的,在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁将不会有效,必须膨胀为重量级锁。
在这里插入图片描述

13、偏向锁

  大多数情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。偏是指偏心,它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。下面是一个使用偏向锁的简单示例:

public class BiasedLockDemo {
    // 创建一个对象
    private static Object lock = new Object();

    public void foo() {
        // 同步块
        synchronized (lock) {
            // 这里是被锁保护的代码块
            // 只允许一个线程访问该代码块
            // ...
        }
    }
}

  在上面的代码中,我们使用了一个 synchronized 块来保护一个代码块,这个锁是偏向锁。在 foo() 方法中,我们使用 synchronized 关键字来获取锁,如果只有一个线程访问同步块,JVM 会自动将锁的状态标记为偏向锁,避免了线程之间的竞争。
  偏向锁可以提高单线程程序的性能,避免线程之间的竞争。但是需要注意的是,在多线程环境下,偏向锁可能会失效,需要重新获取锁,因此需要根据具体的场景来选择使用偏向锁还是其他锁机制。

14、分段锁

  分段锁其实是一种锁的设计,并不是具体的一种锁,具体在 ConcurrentHashMap JDK1.7 版本有所体现。ConcurrentHashMap原理:它内部细分了若干个小的 HashMap,称之为段(Segment)。默认情况下一个 ConcurrentHashMap 被进一步细分为 16 个段,既就是锁的并发度。如果需要在 ConcurrentHashMap 添加一项key-value,并不是将整个 HashMap 加锁,而是首先根据 hashcode 得到该key-value应该存放在哪个段中,然后对该段加锁,并完成 put 操作。在多线程环境中,如果多个线程同时进行put操作,只要被加入的key-value不存放在同一个段中,则线程间可以做到真正的并行。
在这里插入图片描述

15、同步锁

  当多个线程同时访问同一个数据时,很容易出现问题。为了避免这种情况出现,我们要保证线程同步互斥,就是指并发执行的多个线程,在同一时间内只允许一个线程访问共享数据。 Java 中可以使用 synchronized 关键字来取得一个对象的同步锁。

16、死锁

  死锁是一种现象。如线程A持有资源x,线程B持有资源y,线程A等待线程B释放资源y,线程B等待线程A释放资源x,两个线程都不释放自己持有的资源,则两个线程都获取不到对方的资源,就会造成死锁。如下图所示:

线程B
线程A
资源y
资源x

  Java中的死锁不能自行打破,所以线程死锁后,线程不能进行响应。所以一定要注意程序的并发场景,避免造成死锁。

17、锁粗化

  锁粗化是一种优化技术。如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作都是出现在循环体体之中,就算真的没有线程竞争,频繁地进行互斥同步操作将会导致不必要的性能损耗,所以就采取了一种方案:把加锁的范围扩展(粗化)到整个操作序列的外部,这样加锁解锁的频率就会大大降低,从而减少了性能损耗。

18、锁消除

  锁消除是一种优化技术。就是把锁干掉。当Java虚拟机运行时,发现有些共享数据不会被线程竞争时就可以进行锁消除。那如何判断共享数据不会被线程竞争?利用逃逸分析技术分析对象的作用域,如果对象在A方法中定义后,被作为参数传递到B方法中,则称为方法逃逸;如果被其他线程访问,则称为线程逃逸。在堆上的某个数据不会逃逸出去被其他线程访问到,就可以把它当作栈上数据对待,认为它是线程私有的,同步加锁就不需要了。

结语

  本文Java中常用的锁以及常见的锁的概念进行了基本介绍,限于篇幅以及个人水平,没有在本篇文章中对所有内容进行深层次的讲解。其实Java本身已经对锁本身进行了良好的封装,降低了小伙伴们在平时工作中的使用难度。在编写多线程程序时,需要特别注意共享资源的访问和操作,避免出现竞态条件等问题,确保程序的正确性和稳定性。同时,也需要注意多线程的性能问题,合理使用锁机制,避免过多的锁竞争导致程序的性能下降。

参考资料

在这里插入图片描述

  • 20
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

独泪了无痕

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值