当前位置: 首页 > news >正文

JUC编程monitor、锁膨胀以及相关关键字

本文大约需要20分钟阅读。首先讲述了关于Java的synchronized及其原理,进而引出其策略和锁膨胀过程。除此之外还介绍了park、wait等同样的方法进行加锁。最后聚焦于多把锁,介绍reentrantLock。

并发的共享模型和非共享模型:共享模型是共享同一块内存来进行通信,例如锁、volatile、信号量等。非共享就是消息传递模型,例如消息队列。

这里主要是共享模型:

2.1 synchronized

例如两个线程,对初始值一个自增一个自减,结果不一定是0。从字节码上来看,实际上不是一条++或者–就执行结束。这里的for循环就是临界区,即对于共享资源进行读写操作的代码块。因此我们需要一种方式使得非原子的操作变为原子操作。

static int cnt = 0;public static void main(){Thread t1 = new Thread(() -> {for(int i=0; i<100; i++){cnt++;}});Thread t2 = new Thread(() -> {for(int i=0; i<100; i++){cnt--;}});t1.start();t2.start();t1.join();t2.join();// 输出cnt
}

synchronized,即对象锁(只能锁对象,或者说引用类型,不可以对基本类型加锁),采用互斥的方式使得同一时间只有一个线程可以获取得到锁。

synchronized(object){// critical section
}
如果是
synchronized(this){// 即对当前对象进行加锁
}

this是只 当前对象。例如在构造函数里,this.name=name即把传入的name参数赋值给当前对象的name这个成员变量。在spirngboot中,AOP(例如@transactional等)本质上利用代理对象实现,因此如果在bean方法里使用this,是子调用,不会被代理拦截到,因此注解会失效。

@Service
public class DemoService {@Transactionalpublic void foo() {// 事务会生效}public void bar() {this.foo();  // 通过this调用,不走Spring代理,事务不生效!// 应该通过代理对象调用}
}

同时,synchronized还可以锁方法:但是注意,synchronized锁的只是对象,不是方法!!!等同于第二个代码段:

public synchronized void increment(){cnt++;
}
// 实际上是这样的: 锁当前对象
public void increment(){synchronized(this){cnt++;}
}
// 如果synchronized锁的是静态方法,那么锁的也不是方法,而加锁的是类对象
class Test{public synchronized static void test(){}
}
// 等同于
class Test{public static void test(){synchronized(Test.class){// 给类对象进行加锁}}
}

习题:多线程synchronized——线程“八锁”_线程8锁-CSDN博客

2.1.1 变量的线程安全分析:
  1. 对于成员变量和静态变量:如果没有被共享,则是线程安全。如果被共享且包含写操作,则会出现线程问题。因为成员变量是放在堆中,可能会出现线程问题。如下面代码的list如果放在成员变量的位置,会由于arrayList不是线程安全的,导致add和remove操作多次执行后会出现问题。(ArrayIndexOutOfBoundsExcept,例如在add没有执行结束就执行了完整的remove,因此会出现越界错误)
  2. 对于局部变量:是线程安全的。因为一个线程的栈是独立的,局部变量存储在栈中,因此互不影响,是线程安全的。但是如果涉及到子类的时候可能会出问题:
class Test {static final int THREAD_NUMBER = 2;static final int LOOP_NUMBER = 200;public static void main(String[] args){ThreadUnsafe test = new ThreadUnsafe();for(int i=0; i<THREAD_NUMBER; i++){new Thread(() -> {test.method1(LOOP_NUMBER);}, "Thread" + (i+1)).start();}}
}
// example1: 问题在于arraylist是线程不安全的数据结构,因此add操作可能被覆盖
// 出现线程安全的容器也就是为了防止出现线程安全的问题
// 例如多个线程对容器操作 防止线程问题
class ThreadUnSafe {ArrayList<String> list = new ArrayList<>();public final void method1(int loopNumber) {for (int i = 0; i < loopNumber; i++) {method2(list);method3(list);}}private void method2(ArrayList<String> list) {list.add("1");}private void method3(ArrayList<String> list) {list.remove(0);}
}
// 换成局部变量 此时线程安全的问题就会被解决
// 对于每一个线程 都是一个栈中的局部变量 互不干扰
class ThreadSafe {public final void method1(int loopNumber) {ArrayList<String> list = new ArrayList<>();for (int i = 0; i < loopNumber; i++) {method2(list);method3(list);}}// 如果改为public 那么子类可以进行重写 会导致线程安全的问题private void method2(ArrayList<String> list) {list.add("1");}private void method3(ArrayList<String> list) {list.remove(0);}
}
// main中的方法类型改为ThreadSafeSubClass
class ThreadSafeSubClass extends ThreadSafe {@Overridepublic void method3(ArrayList<String> list) {// 启动了一个新的线程来执行 remove 操作。// 这个新的线程和调用 method3 的线程会共享同一个 ArrayList 实例,从而导致线程安全问题new Thread(() -> {list.remove(0);}).start();}
}

因此类修饰符对于线程安全是有意义的:final和private修饰符表示子类不能重写这个方法,而public则会导致子类重写方法,出现线程安全问题。比如:

class A {public synchronized void foo() {// ...线程安全的实现...}
}class B extends A {// 可以选择重写foo,破坏线程安全@Overridepublic void foo() {// ...没有加锁,线程不安全的实现...}
}
A obj = new B(); // 声明类型/引用类型是A,但是实际类型是B
obj.foo();

这里实际调用的是b的方法,因此就没有synchronized,也就无法加锁,可能出现线程安全的问题。

向上转型是子类转向父类,向下转型是父类转向子类。向上是安全的,向下转型是不安全的。向上转型是不需要强制换行,但是向下需要强转。

2.1.2 Monitor和各种锁

java对象头:object header: 每一个java对象在内存中都分为三部分组成:

  • 对象头
  • 示例数据(成员变量
  • 对齐填充 padding

对象头一般包括两部分:mark word和 Klass word.前者是标记字,用于保存对象运行时的数据,例如哈希码,GC年龄分代(survivor或者old),线程状态(无锁\轻量级锁\重量级锁\偏向锁)等。 后者是类型指针,指向对象类型,JVM用这个字段确定这个对象是什么数据类型.

不难看出,JVM会使用java对象头来做锁定\加锁\解锁\计算hashcode\识别对象类型等工作 synchronized 实现加锁也需要java对象头的mark word字段.如下图所示,mark word字段指示了这个java对象是普通对象,还是说加锁了,或者加的是什么类型的锁。
在这里插入图片描述
对于加锁的对象,我们要涉及到monitor。Monitor被称为监视器或者管程. (java中的monitor是JVM实现的,本体和管理结构基本由JVM维护,但是在某些情况,例如线程的挂起和唤醒,需要依靠操作系统的原语操作)。monitor的功能就是来保证对象临界区的线程安全,即提供对于对象的互斥访问。每个java对象可以关联一个monitor对象,如果使用synchronized给对象上锁之后,对象头的mark word就被设置指向monitor对象的指针(当然,这是只有在重量级锁的时候才会直接使用monitor,早期java的synchronized只是重量级锁,再慢慢演化多出了偏向锁、轻量级锁等策略。我们这里按照时间顺序先讲述重量级锁,后面再加入现代的策略)。

多个线程获取monitor的过程: 首先thread2执行临界区代码 因此获得锁, 更改obj的对象头, 并指向monitor的owner字段, 即现在monitor的主人是thread2 当又有线程执行到临界代码的时候,则会检查对象的monitor 发现有主人, 则进入entryList, 一个等待队列,并进入blocked的状态。

在这里插入图片描述

等到Thread2释放锁,则会唤醒entryList里的线程,成为新一个主人,获得monitor. 因此monitor会充当锁这个角色. 上面的waitSet是给wait\notify机制服务的, wait线程会进入waitSet . (总结一下: 如果某个方法加了锁(本质就是加载obj上),那这个对象就会和一个monitor进行关联 然后有线程执行到临界代码 如果monitor还没主人 就会成为monitor的主人) 注意, 这里的唤醒线程往往是非公平的.

在jdk1.6之前,synchronized是直接依赖操作系统的Monitor,也就是管程的,因此每次加锁和释放锁都需要操作系统的帮助,性能很差,因此被称为重量级锁。为了解决这个问题,在jdk1.6之后,加入了一系列的优化,例如偏向锁和轻量级锁。这也导致了synchronized在很多情况下已经不是重量级锁了。(虽然可以使用retrantLock,更加灵活)。

  • 偏向锁是没有线程竞争时,甚至不需要加锁,性能最高。
  • 轻量级锁: 有多个线程但应用层面没有真实竞争,则用用户态自旋和CAS操作,不用阻塞线程。
  • 只有真正发生激烈竞争(线程需要阻塞)时,才会升级成重量级锁(Monitor)

当一个object被加锁时,JVM会根据争用的情况,按照下面的顺序升级锁。升级锁的过程是不可逆的,也就是说,如果对于一个对象的访问竞争越来越激烈,那就会将锁的等级一直升级,直到重量级锁。

具体流程如下

  1. 代码进入 synchronized,先看对象头有没有锁(偏向锁/轻量级锁,这里的图解可以看上面的obj header的格式图,biased_lock=1且lock字段是01是偏向锁,00是轻量级锁),如果是线程基本没竞争,大多数时间锁只能被一个线程获取到,就使用偏向锁。一旦检测到有两个线程竞争,则撤销偏向锁,升级到轻量锁。
  2. 轻量锁这一阶段,有多个线程请求锁,但是没实际竞争(没阻塞,轻量级锁仍是在用户态的,不会陷入内核态),线程会使用CAS机制进行竞争锁,这个竞争过程只在用户态,不会阻塞线程。如果一个线程较长时间持有锁,JVM就会进行自旋(若干次忙等待)尝试再获取锁。也就是,一个线程拿到锁,其它多个线程时尝试自旋。 实在竞争不过或自旋多次失败,会将锁膨胀为重量级锁,使用 monitor(重量级锁),并把未获取到锁的线程直接放入EntryList队列阻塞。: 自旋的操作只在多核cpu才有意义,如果是单核cpu,自旋纯粹是在浪费时间。多核cpu下,没抢到资源的线程进行自旋,抢到资源的线程执行操作即可。
  3. 重量级锁就会涉及线程阻塞, monitor 阶段与操作系统的互斥同步机制配合,用于实际线程挂起与唤醒。对于竞争的线程,能获取到锁的线程就会成为monitor的owner,获取不到锁的则会被放入EntryList,并进行排队,当然,这个唤醒的策略往往是非公平的。
  4. 可以看出,只有重量级锁可以实现让权等待,轻量级锁只能进行空转,这在cpu的策略上重量级锁更高明一点。但是资源角度,轻量级锁的消耗更低,不涉及OS的操作,不涉及上下文切换,只是浪费一点cpu的时间。需要有锁升级的触发机制,来判断锁是否需要升级。具体机制靠的是竞争和失败的数量 来判断是否需要升级锁。

下面的代码就可以得出锁碰撞的过程,刚启动时,是偏向锁,只有t1进入,因此效率极高。当t2也进入时,检测到多个线程,则升级为轻量级锁,会使用CAS的机制进行竞争。当一个线程自旋到一定地步后,则会升级为重量级锁,则访问临界区都需要monitor。

public class LockUpgradeDemo {private static final Object lock = new Object();public static void main(String[] args) throws Exception {Thread t1 = new Thread(() -> {for (int i = 0; i < 50; i++) {synchronized (lock) {try { Thread.sleep(5); } catch (InterruptedException ignored) {}}}}, "T1");t1.start();// 保证T1先运行,让lock对象偏向T1Thread.sleep(100);Thread t2 = new Thread(() -> {for (int i = 0; i < 50; i++) {synchronized (lock) {try { Thread.sleep(5); } catch (InterruptedException ignored) {}}}}, "T2");t2.start();}
}

下面是锁的几个阶段的详解:

(1)无锁(No Lock)

没有锁,也就没有竞争。

(2)偏向锁(Biased Locking)

  • 偏向锁是一种优化手段,假设大多数时间锁只会被同一个线程获取。

  • 第一次线程获得锁时,会在对象头中记录下该线程的ID,后续只要还是此线程进入同步块,不做额外的同步操作(几乎没有性能损耗),提高性能。也就是第一次使用cas设置mark word头,后续没有竞争,则不需要cas,直接可以使用,相当于无锁。

  • 一旦有第二个线程争用该锁,偏向锁就会被“撤销”,进入轻量级锁阶段。

  • 对象创建时,会默认是开启偏向锁的,也就是mark word的后三位是101,但是会有一个延时时间,可以通过修改参数取消延迟。

  • 偏向锁撤销分多钟情况:

    1. 调用对象的hashcode方法,这是因为偏向锁的依赖mark word字段,而hashcode()计算结果也要存在mark word里,因此调用hashcode方法就回撤销偏向锁。
    2. 被其他线程获取,即多个线程竞争一个临界资源,会进行锁的升级
    3. 调用wait()/notify()/notifyAll(),因为这些本质上是属于monitor的内容,因此会直接升级为重量级锁。
  • 偏向锁是可以批量重偏向和批量撤销的。对于批量重偏向:如果一个“类的同一批对象”逐渐表现出“偏向于不同线程”时(如线程池频繁切换锁、不同线程反复访问同类对象),JVM会触发批量重偏向 :让所有对该类的对象重新偏向于新线程ID,提高效率。对于批量撤销:如果 JVM 检测到某类对象多次进行了偏向锁撤销(即太多对象反复发生线程切换、锁升级),会批量禁止这类对象继续进入偏向锁 状态

(3)轻量级锁(Lightweight Locking)

  • 如果有多个线程同时请求锁,但没有实际的竞争(即没有线程阻塞),JVM 会使用 CAS 操作尝试获取锁,把对象头 Mark Word 记录在栈帧中的锁记录里。

  • 详细说一下CAS,即compare and switch,是无锁并发算法的基础,通常三个参与者,内存位置V,原始值A,新值B。如果V等于A,则赋值为B,否则不变。

    boolean CAS(V, A, B) {if (V == A) {V = B;return true;}return false;
    }
    

    第一次

    在这里插入图片描述

    00是轻量级锁,01是偏向锁或者无锁,当发现这两个标识位不相同时,则可以进行交换。交换结果如下图,将线程中的锁记录和object的对象头中字段交换。

    在这里插入图片描述

    其他线程进入,如果发现已经是其他线程交换过,则进行自旋。因此出现按cas失败有两种情况:第一种是其他线程持有轻量级锁,因此开始锁膨胀的过程。第二种则是当前线程执行了另一段代码,也是加了synchronized修饰,即锁重入,那么就再添加一个Lock Record进行重入的计数。如下图所示,表示锁重入。注意,这里的Lock Record的第一个字段为null。(重入是null,如果是cas进行的加锁,则会换来java header的内容)

    在这里插入图片描述

    也因此,退出一次synchronized的代码块,且第一个字段为null,就会解除一个Lock Record,代表是退出可重入的锁。但如果发现部位null,则会使用cas将mark word字段恢复给对应对象的对象头。如果恢复成功,则说明解锁成功。如果失败,则说明锁膨胀或1变为重量级锁。

  • 这种情况下线程不会被真正阻塞,提升了并发性能(自旋),只有发生竞争时才升级为重量级锁。

(4)重量级锁(Heavyweight Locking/Monitor)

  • 如果自旋失败了(有实际的锁竞争,即有线程阻塞了),JVM 就会将锁升级为重量级锁,系统层面阻塞线程,性能损耗最大。
  • 就是我们传统层面说的“操作系统互斥量”(monitor),需要操作系统切换线程,阻塞和唤醒都需要较大代价。
2.1.3 逃逸分析和锁消除

逃逸分析是一种jvm的动态分析技术。可以在JIT编译期间分析出:一个变量的作用域是否只在当前线程、当前方法/块内,是否会逃到方法/线程之外被别人访问 。一般分为三种情况:

  • 无逃逸(No Escape) :对象只在当前方法/线程内被使用,不会暴露到外部,也就是线程私有。
  • 方法逃逸(Arg Escape) :对象作为参数传到其它方法,但没有超出线程范围。
  • 线程逃逸(Global Escape) :对象被赋值到堆上、返回给外部、跨线程,所以有并发问题。

如果分析到有逃逸的,可以使用锁消除进行消除。锁消除 是一种编译器优化技术,目的在于消除那些不必要的加锁操作。例如多余的synchronized。

2.2 notify和wait

wait、notify和notifyAll都是object中的方法。都需要已经获取了对象锁,成为owner才能调用这些方法。也就是已经是blocked状态。

对于waiting和blocked:

synchronizedwait
状态重量级锁,没抢到锁的线程会进入EntryList进行等待,并且状态变为BLOCKED是在进入sync后调用,可能是出现运行条件不满足的情况,此时调用wait方法会从BLOCKED变为WAITING状态,并且释放锁
资源不会占用cpu不会占用cpu
唤醒情况blocked会在owner线程释放锁的时候被唤醒waitng状态下,线程会在owner线程调用notify或者被notifyAll的情况被唤醒,但唤醒了也不会立即获得锁,而是继续进入EntryList进行阻塞竞争

notify是在waitSet挑一个唤醒,notifyAll是都唤醒。wait可以是无参也可以是有参,无参即无限制等待,带参数则是有限制等待。

如何使用wait和notify

sleep和wait的区别?

  1. sleep是Thread的方法,而wait是object的方法
  2. sleep随时可用,但是wait需要和synchronized一起使用,即先获取到对象锁才可以。
  3. sleep在睡眠的时候不会释放对象锁,而wait可以释放。即被上锁时,sleep会继续保持对象锁然后持续运行。但是wait则会释放cpu和对对象的控制权。
  4. sleep和wait调用之后,状态都会进入TIMED_WAITING

在下面的代码下,因为sleep的时候不会释放对象锁,因此sleep的时候,其他线程无法访问这个room。因此问题是,sleep不会释放锁(这和线程切换不一样),会导致其他线程也会阻塞。所以我们考虑使用wait和notify

顺便复习一下锁的状态:锁是一开始处于偏向锁的部分,然后由于出现了其它的线程进行竞争,会升级为轻量级锁或者重量级锁,而其他线程会因为room加锁,会无法进入临界区,因此也只能被阻塞在synchronized (room)这里(monitor的EntryList)。如果还属于轻量级锁的阶段,则其他线程会比对自己的lock record和对象的mark word ,因此cas操作就会失败,会进入自旋。过多的自旋会导致锁升级为重量级锁。

public class TestCorrectPostureStep1 {static final Object room = new Object();static boolean hasResourceA = false;public static void main(String[] args) {new Thread(() -> {synchronized (room) {Log.debug("有资源A没?[{}]", hasResourceA);if (!hasResourceA) {Log.debug("没有资源A,先歇会!");sleep(2);// room.wait(); 使用wait就可以做到释放锁}Log.debug("有资源A没?[{}]", hasResourceA);if (hasResourceA) {Log.debug("可以开始干活了");}}}, "张三").start();for (int i = 0; i < 5; i++) {new Thread(() -> {synchronized (room) {Log.debug("可以开始干活了");}}, "其它人").start();}sleep(1);new Thread(() -> {synchronized(room){hasResourceA = true;log.debug("资源A送到了");//room.notify();  使用notify就可以}}, "送资源A的").start(); }private static void sleep(int seconds) {try {Thread.sleep(seconds * 1000);} catch (InterruptedException e) {e.printStackTrace();}}
}

如果改成wait和notify,张三休息就可以其他线程工作,送资源的人会主动叫醒张三。

但如果有其他线程也需要wait呢

例如说有一个李四,需要资源B,但是唤醒的线程(notify)可能会唤醒张三,但是张三是需要A,而输出资源的线程给出的是B,因此会出现错误唤醒(虚假唤醒)。如果改成notifyAll呢?这样工作的线程都会被唤醒,并且李四会获得资源B开始工作,但是张三被唤醒仍然没办法得到资源A,也就是送资源B的线程唤醒了张三,但是张三未获得资源A,也被错误唤醒。因此我们可以使用while,而不是if。

public class TestCorrectPostureStep1 {static final Object room = new Object();static boolean hasResourceA = false;static boolean hasResourceB = false;public static void main(String[] args) {new Thread(() -> {synchronized (room) {Log.debug("有A没?[{}]", hasResourceA);while (!hasResourceA) {Log.debug("没有,先歇会!");try {room.wait(); // 使用wait就可以做到释放锁} catch (InterruptedException e) {e.printStackTrace();}}Log.debug("有A没?[{}]", hasResourceA);if (hasResourceA) {Log.debug("可以开始干活了");}}}, "张三").start();new Thread(() -> {synchronized (room) {Log.debug("有资源B没?[{}]", hasResourceB);while (!hasResourceB) {Log.debug("没资源B,先歇会!");try {room.wait(); // 使用wait就可以做到释放锁} catch (InterruptedException e) {e.printStackTrace();}}Log.debug("有资源B没?[{}]", hasResourceB);if (hasResourceB) {Log.debug("可以开始干活了");}}}, "李四").start();sleep(1);new Thread(() -> {synchronized(room){hasResourceB = true;log.debug("资源B送到了");room.notifyAll();  }}, "送资源B").start(); }private static void sleep(int seconds) {try {Thread.sleep(seconds * 1000);} catch (InterruptedException e) {e.printStackTrace();}}
}

这样的输出,最后张三被唤醒,但是第二次调用wait会一直被阻塞,因为没人唤醒。注意,分配处理机和是否被阻塞是两回事。

因此可得到一个套路

synchronized(lock){while( 条件不成立 ){lock.wait();}// 干活
}// 另一个线程
synchronized(lock){lock.notifyAll();
}

2.3park&Unpark

都是LockSupport中的方法。

// 阻塞当前线程
Locksupport.park();
//唤醒某个线程
Locksupport.unpark(暂停线程对象)
// 先park再unpark
Thread t1=new Thread(()->{log.debug("start...");sleep(1);log.debug("park...");Locksupport.park();log.debug("resume...");
},"七1");
t1.start();sleep(2);
log.debug("unpark...");
Locksupport.unpark(t1);

和wait之类有什么区别?

  • 两种方式都是阻塞线程,但是wait之类的必须和monitor一起用,但是unpark不需要,unpark更底层和原子。
  • park和unpark必须以线程为单位来阻塞和唤醒,而notify只能随即唤醒一个等待线程,notifyAll是全部唤醒,因此不精确。
  • unpark可以先执行,但是notify不可以在wait之前执行。

park和unpark原理

park/unpark 是Java的并发包java.util.concurrent.locks.LockSupport中的两个底层线程操作。它们本质上依赖于 JVM 对应平台的线程阻塞/唤醒API。每个线程对象里有一个“许可”标记(0/1)。

  1. park操作:
    • 如果许可为1,park会直接消耗掉许可(变成0),线程不阻塞。
    • 如果许可为0,线程Blocking,进入等待队列,挂起(底层调用os futex、cond.V、mutex等实现真正阻塞)。
  2. unpark操作:
    • 设置目标线程的“许可”为1。
    • 如果线程在park阻塞,unpark会唤醒它(通知底层os恢复运行)。
    • 如果unpark在park前发生,等park到来时直接消耗掉许可继续运行。

每个线程都有一个自己的parker对象,由三部分组成:_counter, _cond和 _mutex.当调用park的时候,当前线程调用Unsafe.park()方法,检查_counter,如果许可是0,就会获得_mutex互斥锁,线程进入_cond条件变量阻塞,设置_counter=0;

在这里插入图片描述

  • _mutex :代表互斥锁(mutex),用于实现线程同步互斥(只有一个线程能进入临界区)。
  • _cond :代表条件变量(condition variable),用于线程间的条件等待/唤醒(比如条件不满足时wait,满足时notify)。
  • _counter :一般用于信号量计数、或等待队列计数等辅助信息。

2.4 状态转换

  • NEW :新建状态(线程对象已创建,尚未调用start())。
  • RUNNABLE :可运行/运行中(已调用start,可能正在CPU上运行,也可能在就绪队列中等待,Java没有专门的“Running”状态,統一称为“Runnable”)。
    • 图中绿色文字区分了:“运行状态”(抢到CPU)和“可运行状态”(就绪但未抢到CPU)。
  • BLOCKED :阻塞状态(等待获得对象监视器锁,比如synchronized时争锁失败)。
  • WAITING :等待状态(无限期等待被其它线程显式唤醒,如Object.wait()Thread.join()LockSupport.park()等)。
  • TIMED_WAITING :超时等待(有限期等待,如wait(long)Thread.sleep()join(long)等)。
  • TERMINATED :终止状态(run方法执行完毕或因异常退出,线程生命周期结束)。

在这里插入图片描述

runnable -> blocked:例如synchronized,竞争锁失败变为blocked状态。

runnable -> waiting:

  1. 调用synchronized,竞争锁成功,之后调用wait则会变为waiting。
  2. 调用join方法,线程会从runnable转为waiting。
  3. 调用park方法。
  4. 调用interrupt可以转回runnable

timed_waitng基本上是调用有参数的wait、join或者sleep,则会变为waiting。

2.5 多把锁

有时候可以修改锁的粒度,进而增加并发度。例如两个线程a、b需要做对应的业务,如果ab访问一个对象,则会出现并发度低的问题。例如a要睡觉,b要学习,一个room class进行睡觉和学习,如果对于room object进行加锁(synchronized),则只能串行进行。但如果class里包括两个变量一个用来学习,一个用来睡觉,则单独加锁即可,这样就可以提高并发度。

但问题是,如果一个线程需要获取多把锁,这样就可能会出现死锁。例如a有t1锁,想获取t2锁;但是b有t2锁,想要t1锁。这样会出现死锁。

怎么定位死锁?可以使用jconsole工具/jps定位进程id,再用jstack定位死锁。例如我们使用jps的指令,示例如下:

# jps
8473 Jps
8421 ExampleApp
6102 HelloWorld
# jstack 8421
Found one Java-level deadlock:
=============================
"Thread-1":waiting to lock monitor 0x00007fb7ac010f78 (object 0x00000000c011b668, a java.lang.Object),which is held by "Thread-2"
"Thread-2":waiting to lock monitor 0x00007fb7ac010f10 (object 0x00000000c011b660, a java.lang.Object),which is held by "Thread-1"Java stack information for the threads listed above:
===================================================
"Thread-1":at Deadlock.main(Deadlock.java:13)- waiting to lock <0x00000000c011b668> (a java.lang.Object)- locked <0x00000000c011b660> (a java.lang.Object)
"Thread-2":at Deadlock.main(Deadlock.java:14)- waiting to lock <0x00000000c011b660> (a java.lang.Object)- locked <0x00000000c011b668> (a java.lang.Object)Found 1 deadlock.

也就是两个线程都持有一个锁,并且再等待彼此的锁。

哲学家进餐问题:

下面的代码直接写会出现死锁,例如每个哲学家都拿到的是第一个筷子,会出现都缺少第二个筷子的问题。

public class TestDeadLock{public static void main(string[]args){Chopstick c1=new chopstick("1");Chopstick c2 = new chopstick("2");Chopstick c3= new chopstick("3");Chopstick c4=new Chopstick("4");Chopstick c5 =new Chopstick("5");new Philosopher("苏格拉底",c1, c2).start();new Philosopher("柏拉图",c2, c3).start();new Philosopher("亚里士多德",c3, c4).start();new Philosopher("赫拉克利特",c4, c5).start();new Philosopher("阿基米德",c5, c1).start();}
}class Philosopher extends Thread {Chopstick left;Chopstick right;public Philosopher(String name, Chopstick left, Chopstick right) {super(name);this.left = left;this.right = right;}@Overridepublic void run() {while (true) {// 尝试获得左手筷子synchronized (left) {// 尝试获得右手筷子synchronized (right) {eat();}}}}private void eat() {Log.debug("eating...");Sleeper.sleep(1);}
}class Chopstick {String name;public Chopstick(String name){ this.name = name;}@Overridepublic String toString(){ return"筷子{"+ name + '}';
}

活跃锁/活锁:

例如cnt=10,一个线程是cnt++,另一个是cnt–,且每次cnt操作之后会sleep一会,且每个线程都是cnt到具体值才退出循环(例如一个到0退出,一个到20),这样由于sleep,两个线程会一直执行。也就是说,两个线程互相改变值导致都无法达到结束条件,这是活锁。解决办法例如可以是增长sleep的时间避免活锁产生。

饥饿:

解决死锁可以让线程有顺序的获取锁,例如都是先获取a再获取b,这样就不会死锁,但可能导致饥饿的问题。例如上面的哲学家进餐问题,把最后的c5,c1改为c1,c5就可以解决死锁问题。但是jvm的synchronized不是公平锁,因此不保证调度是公平的,因此可能某个线程释放了锁就立马抢回来,因此会出现饥饿。解决的方法是reentrantLock。

⭐ReentrantLock

可重入锁属于util.concurrent.locks 包下的内容。ReentrantLock 并不依赖于 monitor,也不涉及对象头,也不绑定到对象上,而是JUC包纯Java实现的同步器 ,也因此不会涉及锁膨胀的过程,而是同步工具,根据是否枪锁成功决定线程是阻塞还是运行,不会进行锁升级。而相比于synchronized,ReentrantLock有如下特点:

  1. 可以中断
  2. 可以设置超时时间
  3. 可以设置为公平锁
  4. 支持多个条件变量
  5. 需要手动释放锁,而synchronized不需要手动释放。

reentrantLock的用法大致如下:

Lock lock = new ReentrantLock();
lock.lock();
try{// 临界区
}finally{lock.unlock(); // 一定要释放锁
}

可重入:第一次获取锁后,就会成为锁的拥有者。如果是不可重入锁,会在第二次获取得到锁的时候,自己也会被锁拦住。可重入的优点在于,同一线程可以多次访问自己锁保护的临界区,而不会被阻塞。例如递归调用,a和b都是被synchronized修饰的,a调用b方法,这样就不会被阻塞。(当然,synchronized也是可重入的,上面的关于monitor的原理有解释)

public class test {private static ReentrantLock lock = new ReentrantLock();private static void main(Stings[] args){lock.lock();try{m1();}finally{lock.unlock(); // 一定要释放锁}}private static void m1(Stings[] args){lock.lock();try{m2(); // 可以重入}finally{lock.unlock(); // 一定要释放锁}}
}

可打断synchronized不能响应中断,ReentrantLocklockInterruptibly可以在等待锁时被其他线程中断,避免永久阻塞。

public class test {private static ReentrantLock lock = new ReentrantLock();private static void main(Stings[] args){new Thread(() -> {try{// 没有竞争则可以获得lock对象锁// 如果有竞争则进入阻塞队列  可以被其他线程的interrupt打断lock.lockInterruptibly();}catch{// 打印,被打断了return;}try{// 临界区}finally{lock.unlock(); // 一定要释放锁}},"t1");lock.lock();// 这样获取不到锁t1.start();// t1.interrupt(); 打断}
}

锁超时

可打断是其他线程来打断,而锁超时是主动超时不再获取锁. trylock无参是如果等不到锁,则立即返回。有参则是等待时长为参数的时长,例如参数为1s,则是等待1s,如果在1s内等到则获取锁,否则不继续等待,主动放弃。

public class test {private static ReentrantLock lock = new ReentrantLock();private static void main(Stings[] args){Thread t1 = new Thread(() -> {if(!lock.trylock()){// 获取不到锁return ;}try{// 获取到锁 }finally{lock.unlock(); // 一定要释放锁}},"t1");t1.start();}
}

因此可以使用trylock对哲学家进餐问题进行更改,使用synchronized不会主动释放,而改成trylock则可以主动释放。这样实操时基本可以解决哲学家进餐的死锁和饥饿。

公平锁

monitor释放的时候,entrylist不会按照进入队列的顺序来获取锁,而是直接争抢,因此不是公平锁。ReentrantLock是公平锁,创建的时候默认是false,即为不公平锁。而传入参数为True,则会变为公平锁,按照先入先得这样的顺序获取锁。但是公平锁往往没必要,会降低并发度。不如使用trylock。

条件变量

synchronized中的条件变量就是waitSet的休息室,当条件不满足时进入waitSet等待。而ReentrantLock的条件变量更好,因为支持多个条件变量。因此,synchronized是把不满足的都放入waitSet,唤醒也是在waitSet中随即唤醒。而lock则是分多个休息室,每个休息室都是专门等待某些条件的线程。细致来说,每个condition对象有独立的等待队列,实际上是AQS中的conditionQueue链表,可以针对性的唤醒指定队列中的线程,灵活度远高于synchronized。

await之前需要获取锁,await执行后进入conditionObject进行等待。如果被唤醒/打断/超时,则重新竞争lock锁。

public class test {private static ReentrantLock lock = new ReentrantLock();private static void main(Stings[] args){// 创建单独的条件变量(休息室)Condition condition1 = lock.newCondition();Condition condition2 = lock.newCondition();lock.lock();// 进入休息室1condition1.await();condition1.await(1); // 有时限// 其他线程通知condition1.signal();condition1.signalAll();}
}

相关文章:

  • 友思特应用 | LCD显示屏等玻璃行业的OCT检测应用
  • 基于正点原子阿波罗F429开发板的LWIP应用(2)——设置静态IP和MAC地址修改
  • 进程之IPC通信一
  • 51单片机编程学习笔记——无源蜂鸣器演奏《祝你生日快乐》
  • 大模型服务如何实现高并发与低延迟
  • SAR ADC 比较器寄生电容对性能的影响
  • OSError: [WinError 193] %1 不是有效的 Win32 应用程序。
  • [特殊字符] jQuery 响应式瀑布流布局插件推荐!
  • 王树森推荐系统公开课 排序04:视频播放建模
  • Mybatis面向接口编程
  • Conda环境管理:确保Python项目精准复现
  • 基于Qwen3-7B FP8与基石智算打造高性能本地智能体解决方案
  • 【Java高阶面经:微服务篇】1.微服务架构核心:服务注册与发现之AP vs CP选型全攻略
  • C++:STL
  • 2025华为OD机试真题+全流程解析+备考攻略+经验分享+Java/python/JavaScript/C++/C/GO六种语言最佳实现
  • lasticsearch 报错 Document contains at least one immense term 的解决方案
  • 大模型预训练、微调、强化学习、评估指导实践
  • Token的组成详解:解密数字身份凭证的构造艺术
  • ragas precision计算的坑
  • JavaScript计时器详解:setTimeout与setInterval的使用与注意事项
  • 国家能源局:成立核电工程定额专家委员会
  • 济南维尔康:公司上届管理层个别人员拒不离岗,致多项业务难以推进
  • 11次战斗起飞应对外军挑衅,逼退外军直升机细节曝光
  • 中国代表:美国才是南海安全稳定的最大威胁
  • 演员朱媛媛去世,其丈夫辛柏青发讣告
  • 深圳南山法院回应“执行款未到账”:张核子公司申请的执行异议成立