Java锁-锁的分类+ReentrantLock+LockSupport

it2025-07-06  6

文章目录

1. Java锁的种类1.1 乐观锁/悲观锁1.1.1 乐观锁(1) 数据版本机制(2) CAS操作 1.2 悲观锁 1.2 排他锁/共享锁1.3 读写锁1.4 可重入锁1.5 公平锁/非公平锁1.6 分段锁1.7 偏向锁/轻量级锁/重量级锁1.8 自旋锁 2. Locks包3. Lock接口2.1 显示锁和隐式锁2.2 Lock接口规定的方法2.3 ReentrantLock2.3.1 使用示例2.3.2 ReentrantLock对比于synchronized的优点 2.4 ReentrantReadWriteLock2.5 LockSupport2.5.1 阻塞/唤醒线程的功能 2.6 新锁的底层实现(AQS )4. Condition接口

1. Java锁的种类


Java多线程中锁的理解与使用_天涯的专栏-博客_java 线程锁

在笔者面试过程时,经常会被问到各种各样的锁,如乐观锁、读写锁等等,非常繁多,在此做一个总结。介绍的内容如下:

乐观锁/悲观锁独享锁/共享锁互斥锁/读写锁可重入锁公平锁/非公平锁分段锁偏向锁/轻量级锁/重量级锁自旋锁

以上是一些锁的名词,这些分类并不是全是指锁的状态,有的指锁的特性,有的指锁的设计,下面总结的内容是对每个锁的名词进行一定的解释。

1.1 乐观锁/悲观锁

乐观锁与悲观锁并不是特指某两种类型的锁,是人们定义出来的概念或思想,主要是指看待并发同步的角度。

乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS(Compare and Swap 比较并交换)实现的。

悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。比如Java里面的同步原语synchronized关键字的实现就是悲观锁。

悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。

悲观锁在Java中的使用,就是利用各种锁。

乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。

1.1.1 乐观锁

乐观锁总是认为不存在并发问题,每次去取数据的时候,总认为不会有其他线程对数据进行修改,因此不会上锁。但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般会使用“数据版本机制”或“CAS操作”来实现。

(1) 数据版本机制

实现数据版本一般有两种,第一种是使用版本号,第二种是使用时间戳。以版本号方式为例。

版本号方式:一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。 核心SQL代码:

1 update table set xxx=#{xxx}, version=version+1 where id=#{id} and version=#{version};

(2) CAS操作

CAS(Compare and Swap 比较并交换),当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

CAS操作中包含三个操作数——需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B,否则处理器不做任何操作。

1.2 悲观锁

悲观锁认为对于同一个数据的并发操作,一定会发生修改的,哪怕没有修改,也会认为修改。因此对于同一份数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁并发操作一定会出问题。

在对任意记录进行修改前,先尝试为该记录加上排他锁(exclusive locking)。

如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常。具体响应方式由开发者根据实际需要决定。

如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁了。

期间如果有其他对该记录做修改或加排他锁的操作,都会等待我们解锁或直接抛出异常。

1.2 排他锁/共享锁

排他锁(独享锁,互斥锁)是指该锁一次只能被一个线程所持有。

共享锁是指该锁可被多个线程所持有。

对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是排他锁(独享锁)。

读锁的共享锁可保证并发读是非常高效的,读写,写读,写写的过程是互斥的。

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

对于Synchronized而言,当然是独享锁。

1.3 读写锁

上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。

互斥锁在Java中的具体实现就是ReentrantLock。

读写锁在Java中的具体实现就是ReadWriteLock。

1.4 可重入锁


可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。

对于Java ReetrantLock而言,从名字就可以看出是一个重入锁,其名字是Re entrant Lock 重新进入锁。对于Synchronized而言,也是一个可重入锁。

可重入锁的最大的作用是可一定程度避免死锁。

synchronized void setA() throws Exception{   Thread.sleep(1000);   setB(); } synchronized void setB() throws Exception{   Thread.sleep(1000); } public class Test implements Runnable { ReentrantLock lock = new ReentrantLock(); public void get() { lock.lock(); System.out.println(Thread.currentThread().getId()); set(); lock.unlock(); } public void set() { lock.lock(); System.out.println(Thread.currentThread().getId()); lock.unlock(); } @Override public void run() { get(); } public static void main(String[] args) { Test ss = new Test(); new Thread(ss).start(); new Thread(ss).start(); new Thread(ss).start(); } }

上面的代码就是一个可重入锁的一个特点。如果不是可重入锁的话,setB可能不会被当前线程执行,可能造成死锁。

1.5 公平锁/非公平锁

当一个线程获取到锁的时候,其他的线程在等待,其实它们是在一个队列中。

公平锁是指多个线程按照申请锁的顺序来获取锁。它会检查一下队列,如果队列中有线程,则把这个线程插入到队列的尾部。

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。也就是它不会检查队列,一上来就开始抢锁。

对于Java ReetrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。

对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。

1.6 分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap(JDK7和JDK8中HashMap的实现)的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表;同时又是一个ReentrantLock(Segment继承了ReentrantLock)。

当需要put元素的时候,并不是对整个hashmap进行加锁,而是先通过hashcode来知道他要放在哪一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。

但是,在统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。

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

1.7 偏向锁/轻量级锁/重量级锁

这三种锁是指锁的状态,并且是针对Synchronized。在Java 5通过引入锁升级的机制来实现高效Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的(Markword)。

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。很多情况下我们使用Synchronized加锁过的代码都只有一个线程在执行,如果这个时候像Java 5一样去操作系统中申请锁就非常的消耗资源。所以在java5之后,如果是这中情况我们就不需要去申请锁,我们在这个锁的markword前54位记录一下这个线程的ID。这就是偏向锁。

轻量级锁是指当锁是偏向锁的时候,有其他线程来争用的时候(所谓的轻度竞争),偏向锁就会升级为轻量级锁,轻量级锁其实就是用自旋锁来实现,通过一个while循环,来占用CPU,从而不需要进入操作系统的就绪队列,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候(所谓的重度竞争),还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让他申请的线程进入阻塞,性能降低。因为如果太多的线程在竞争,在自旋的话就会态消耗CPU的资源了,所以当自旋的线程达到一定数量,或自旋次数超过一定数量时(10次),我们就干脆把它放到阻塞队列中去。加锁的代码执行时间长,线程数量多的情况下我们要考虑使用重量级锁。

1.8 自旋锁

在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。自旋锁是在用户态中,比经过内核态的重量级锁的效率要高。

2. Locks包


JUC是 Java.util.concurrent包的简称,这个包包括了java下绝大部分的并发编程所要用到的东西。Locks包是JUC下的一个子包,它提供了用于锁定和等待条件与内置的同步和监视器不同的框架。该框架在使用锁和条件方面允许更大的灵活性,但代价是语法更加笨拙。

3. Lock接口


2.1 显示锁和隐式锁

Lock接口提供了比synchronized更广泛的锁操作。它们允许更灵活的结构,可能具有完全不同的属性,并且可能支持多个关联的Condition对象。

我们把使用Lock接口实现线程同步的锁称之为 显示锁,使用synchronized的称为 隐式锁 或 java内置锁

2.2 Lock接口规定的方法


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-n4lQP6na-1603325747339)(/Users/luca/MarkText-img-Support/2020-07-25-10-18-17-image.png)]

void lock(); 平常使用得最多的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。

void lockInterruptibly() throws InterruptedException;

boolean tryLock(); 是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待。

Lock lock = ...; if (lock.tryLock()) { try { // manipulate protected state } finally { lock.unlock(); } } else { // perform alternative actions }

boolean tryLock(long time, TimeUnit unit) throws InterruptedException;和tryLock()方法是类似的,如果当时没有获取到锁,会阻塞规定的时间,在这个 规定时间内获取到了锁就返回true,超时但还没有获取到锁则返回False

void unlock(); 解锁

Condition newCondition(); Returns a new condition instance that is bound to this Lock instance.

2.3 ReentrantLock

2.3.1 使用示例


public class LockTest { private static int share ; private static final ReentrantLock lock = new ReentrantLock(true); public static void Method1(){ try { lock.lock(); for (int i = 0; i <100 ; i++) { share++; } } finally { lock.unlock(); } } public static void Method2(){ try { lock.lock(); for (int i = 0; i <100 ; i++) { share--; } } finally { lock.unlock(); } } public static void main(String[] args) { share = 100; new Thread(new Runnable() { @Override public void run() { LockTest.Method1(); } }).start(); // new Thread(LockTest::Method1).start(); 这样的写也可以,使用的是lambda表达式 new Thread(LockTest::Method2).start(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(share); } }

ReentrantLock的底层依旧是CAS

ReentrantLock是一种可重入锁,它可以替代Synchronized,它与Synchronized的区别就是ReentrantLock需要手动加锁和解锁。

但是ReentrantLock的细粒度和灵活度要比Synchronized更细更灵活;比如ReentrantLock提供了tryLock()等方法

注意⚠️: unlock的时候一定要放到finally里面

2.3.2 ReentrantLock对比于synchronized的优点


最大的区别就是ReentrantLock可以使用Condition。

可定时:ReentrantLock提供了tryLock(long ,TimeUnit),在规定的时间内如果获得锁则这个方法返回true,如果没有获得到锁返回False,这样我们就可以做进一步的处理。(没有参数就默认是:tryLock(0, TimeUnit.SECONDS))

可响应被打断:ReentrantLock提供了lockInterruptibly(),Synchronized一旦调用了wait()方法,则必须要等待其他线程notify不然是醒不来的。lockInterruptibly()的作用是:如果当前线程未被中断(未被interrupt()中断,也就是中断标志位为False),则与Lock()没区别,如果已被中断则抛出异常。这个可以用来中断长时间获取不到锁的线程

[这个写过测试,这里就不贴出来了]

可以是公平锁亦可以是非公平: Synchronized是非公平锁,而ReentrantLock可以根据我们传递的参数来创建是那种类型的锁。默认是非公平。

2.4 ReentrantReadWriteLock


读写锁的概念就是共享锁和排他锁,读锁就是共享锁,写锁就是排他锁

ReadWriteLock是一个接口,它下面只规定了两种方法

Lock readLock();

Lock writeLock();

ReentrantReadWriteLock是ReadWriteLock接口的一个实现,我么可以通过ReentrantReadWriteLock中上述的两种方法来分别获得读锁和写锁。

//测试比较:在读写的情况下使用读写锁的速度,与只使用ReentrantLock的速度 public class ReadWriteLockTest { static int value = 100; //通过ReentrantReadWriteLock对象拿到读锁和写锁 static ReadWriteLock readwritelock = new ReentrantReadWriteLock(); static Lock readLock = readwritelock.readLock(); static Lock writelock = readwritelock.writeLock(); //创建一个ReentrantLock static ReentrantLock reentrantlock = new ReentrantLock(); //传入一个锁 我们将传入上述两种不同的锁,对比两种锁的速度 public static void Read(Lock lock){ try { lock.lock(); Thread.sleep(500); System.out.println("读出数据:"+value); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } } //传入一个锁,和一个要修改的值,我们将传入上述两种不同的锁,对比两种锁的速度 public static void Write(Lock lock, int V){ try { lock.lock(); Thread.sleep(500); value = V; System.out.println("写入数据:"+V); } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock(); } } public static void main(String[] args) { //使用第二种方式来创建线程:创建一个Runnable对象,在将对象传入Thread的构造器中 //1. 读锁和写锁的情况 Runnable readR = ()->{Read(readLock);}; Runnable writeR = ()->{Write(writelock,new Random().nextInt());}; //2. 只使用reentrantlock // Runnable readR = ()->{Read(reentrantlock);}; // Runnable writeR = ()->{Write(reentrantlock,new Random().nextInt());}; //启动两个写线程 for (int i = 0; i <2 ; i++) { new Thread(writeR).start(); } //启动18个读线程 for (int i = 0; i <18 ; i++) { new Thread(readR).start(); } } }

2.5 LockSupport


LockSupport是JDK1.6中在java.util.concurrent中的子包locks中引入的一个比较底层的工具类,用来创建锁和其他同步工具类的基本线程阻塞原语。java锁和同步器框架的核心 AQS: AbstractQueuedSynchronizer,就是通过调用 LockSupport.park()和 LockSupport.unpark()实现线程的阻塞和唤醒的。

2.5.1 阻塞/唤醒线程的功能


public static void HowToUse(){ Thread t = new Thread(()->{ for (int i = 0; i <10 ; i++) { if (i == 5){ LockSupport.park(); } try { Thread.sleep(1000); System.out.println(Thread.currentThread().getName() + ": "+i); } catch (InterruptedException e) { e.printStackTrace(); } } }); t.start(); //主线程要执行的事: try { TimeUnit.SECONDS.sleep(8); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() +": 时间过了8秒,我要将线程解封"); LockSupport.unpark(t); }

LockSupport可以通过park() 和 Unpark() 来阻塞/唤醒线程。之前的方式来阻塞或唤醒一个线程是通过 wait(),notify(),来实现的。与之对比LockSupport要更灵活,可以完成它不能完成的任务:

通过wait(),notify()的方式都是需要一个锁(同步监视器)。所以我们必须给要调用的线程加锁;而LockSupport不需要

通过wait(),notify()的方式被阻塞的线程都是放在一个阻塞队列中,当notify()时,它会唤醒一个优先级高的线程;而LcokSupport的unpark()可以指定要唤醒的线程

unpark() 可以先于park()调用,这样的话就相当于使park失效,但是notify() 先于wait()调用的话wait()不会失效。因为notify()是唤醒一个阻塞队列中的线程,当前线程还没有调用wait。还没有进入阻塞队列,所以这个notify对于该线程是不起作用的。

调用notify的线程不会释放锁的,所以如果唤醒的线程与调用notify的线程用的是同一把锁,其实这个这个被唤醒的线程还是需要等这该线程执行完才能去抢锁,除非在这个notify后马上调用wait,让这个线程让出锁来给被唤醒的线程。

我们可以对同一线程park多次,相对的如果我们要让这个线程解封就要对这个线程unpark多少次

LockSupport的park和unpark方法其实是调用了Unsafe类的park,unpark方法。

2.6 新锁的底层实现(AQS )


我们把ReentrantLock,countdownlatch等等在sychronzied后面发布的锁叫做新锁,这写新锁的底层实现都离不开AQS

4. Condition接口

最新回复(0)