JUC-Park和Unpark的使用及原理

Park和Unpark的使用及原理

基本使用

API:他们都是LockSupport中的方法

1
2
3
4
// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象)

先park再unpark

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
 public static void main(String[] args) {
Thread t1 = new Thread(() -> {
log.debug("start ...");
//此时park之后会让线程陷入阻塞状态,需要调用unpark之后才会继续运行
LockSupport.park();
log.debug("unPark");
},"t1");

t1.start();

Sleeper.sleep(3);
log.debug("unpark ...");
LockSupport.unpark(t1);
}


//运行结果
14:08:12.037 c.ParkTest [t1] - start ...
14:08:15.041 c.ParkTest [main] - unpark ...
14:08:15.041 c.ParkTest [t1] - unPark

Process finished with exit code 0

先unpark再park

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
        Thread t1 = new Thread(() -> {
log.debug("start...");
Sleeper.sleep(2);
log.debug("park...");
LockSupport.park();
log.debug("resume...");
}, "t1");

t1.start();

Sleeper.sleep(1);
log.debug("unpark...");
LockSupport.unpark(t1);


//运行结果
14:24:21.319 c.ParkTest [t1] - start...
14:24:22.320 c.ParkTest [main] - unpark...
14:24:23.320 c.ParkTest [t1] - park...
14:24:23.320 c.ParkTest [t1] - resume...

Process finished with exit code 0

通过以上结果可以看出,当线程调用unpark之后会给线程添加唤醒标记,后续park时,会检查是否提前唤醒过。

特点

LockSupport 出现就是为了增强 wait & notify 的功能:

  • wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park、unpark 不需要
  • park & unpark 以线程为单位来阻塞和唤醒线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程
  • park & unpark 可以先 unpark,而 wait & notify 不能先 notify。类比生产消费,先消费发现有产品就消费,没有就等待;先生产就直接产生商品,然后线程直接消费
  • wait 会释放锁资源进入等待队列,park 不会释放锁资源,只负责阻塞当前线程,会释放 CPU

park和unpark的原理

每个线程都有自己的一个 Parker 对象,由三部分组成 _counter , _cond 和 _mutex 打个比喻

  • 线程就像一个旅人,Parker 就像他随身携带的背包,条件变量就好比背包中的帐篷。_counter 就好比背包中的备用干粮(0 为耗尽,1 为充足)
  • 调用 park 就是要看需不需要停下来歇息 如果备用干粮耗尽,那么钻进帐篷歇息; 如果备用干粮充足,那么不需停留,继续前进;
  • 调用 unpark,就好比令干粮充足
    如果这时线程还在帐篷,就唤醒让他继续前进; 如果这时线程还在运行,那么下次他调用 park 时,仅是消耗掉备用干粮,不需停留继续前进

因为背包空间有限,多次调用 unpark 仅会补充一份备用干粮 :意思是说当先uppark再park时,不论开始有多少次unpark操作,也只会有一次生效。只能解锁一次park操作。

类似生产者消费者

  • 先 park:
    1. 当前线程调用 Unsafe.park() 方法
    2. 检查 _counter ,本情况为 0,这时获得 _mutex 互斥锁
    3. 线程进入 _cond 条件变量挂起
    4. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
    5. 唤醒 _cond 条件变量中的 Thread_0,Thread_0 恢复运行,设置 _counter 为 0

  • 先 unpark:

    1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
    2. 当前线程调用 Unsafe.park() 方法
    3. 检查 _counter ,本情况为 1,这时线程无需挂起,继续运行,设置 _counter 为 0


JUC-synchronized优化之锁粗化、锁消除、自旋优化

synchronized优化之锁粗化、锁消除、自旋优化

Jdk 1.5以后对Synchronized关键字做了各种的优化,经过优化后Synchronized已经变得原来越快了,这也是为什么官方建议使用Synchronized的原因,具体的优化点如下。

  • 锁粗化
  • 锁消除
  • 自旋优化

锁粗化

互斥的临界区范围应该尽可能小,这样做的目的是为了使同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,锁粗化就是将「多个连续的加锁、解锁操作连接在一起」,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。

J V M会检测到一连串的操作都对同一个对象加锁(for循环10000次执行j++,没有锁粗化就要进行10000次加锁/解锁),此时J V M就会将加锁的范围粗化到这一连串操作的外部(比如for循环体外),使得这一连串操作只需要加一次锁即可。

锁消除

Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析(对象在函数中被使用,也可能被外部函数所引用,称为函数逃逸),去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的时间消耗。

代码中使用Object作为锁,但是Object对象的生命周期只在incrFour()函数中,并不会被其他线程所访问到,所以在J I T编译阶段就会被优化掉(此处的Object属于没有逃逸的对象)。

自旋优化

Synchronized自旋优化发生在轻量级锁产生竞争后,获取失败的线程将锁膨胀为重量级锁的过程中。失败的线程不会立刻阻塞,而是先尝试适应性自旋,等待所有者释放锁,当到达临界值后再阻塞。

介绍

monitor会阻塞和唤醒线程,线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,这些操作给系统的并发性能带来了很大的压力。

为了让线程等待,我们只需让线程执行一个循环,这项技术就是所谓的自旋锁。

自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长。那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。

因此,自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,用户可以使用参数 -XX : PreBlockSpin 来 更改。

缺点

  1. 单核处理器上,不存在实际的并行,当前线程不阻塞自己的话,旧owner就不能执行,锁永远不会释放,此时不管自旋多久都是浪费;进而,如果线程多而处理器少,自旋也会造成不少无谓的浪费。

  2. 自旋锁要占用 CPU,如果是计算密集型任务,这一优化通常得不偿失,减少锁的使用是更好的选择。

  3. 如果锁竞争的时间比较长,那么自旋通常不能获得锁,白白浪费了自旋占用的CPU时间。这通常发生在锁持有时间长,且竞争激烈的场景中,此时应主动禁用自旋锁。

使用 -XX:-UseSpinning 参数关闭自旋锁优化;-XX:PreBlockSpin 参数修改默认的自旋次数。

适应性自旋锁

在JDK 6中引入了自适应的自旋锁。

自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。
相反的,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能减少自旋时间甚至省略自旋过程,以避免浪费处理器资源。

自适应自旋解决的是 锁竞争时间不确定 的问题。

JVM 很难感知到确切的锁竞争时间,而交给用户分析就违反了 JVM 的设计初衷。自适应自旋假定不同线程持有同一个锁对象的时间基本相当,竞争程度趋于稳定,因此,可以根据上一次自旋的时间与结果调整下一次自旋的时间。

https://juejin.cn/post/6977744582725681182


JUC-wait & notify

在Java的Object类中有2个我们不怎么常用(框架中用的更多)的方法:wait()与notify()或notfiyAll(),这两个方法主要用于多线程间的协同处理,即控制线程之间的等待、通知、切换及唤醒。

API 介绍

  • obj.wait() 让进入 object 监视器的线程到 waitSet 等待
  • obj.notify() 在 object 上正在 waitSet 等待的线程中挑一个唤醒
  • obj.notifyAll() 让 object 上正在 waitSet 等待的线程全部唤醒

底层原理:

  • Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
  • BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
  • BLOCKED 线程会在 Owner 线程释放锁时唤醒
  • WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,唤醒后并不意味者立刻获得锁,需要进入 EntryList 重新竞争

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class WaitNotify {

public static void main(String[] args) {

Object lock = new Object();

// thread1
new Thread(() -> {

System.out.println("thread1 is ready");

try {
Thread.sleep(2000);
} catch (InterruptedException e) {

}
synchronized (lock) {
lock.notify();
System.out.println("thread1 is notify,but not exit synchronized");
System.out.println("thread1 is exit synchronized");
}


}).start();

// thread2
new Thread(() -> {
System.out.println("thread2 is ready");

try {
Thread.sleep(1000);
} catch (InterruptedException e) {

}
synchronized (lock) {
try {
System.out.println("thread2 is waiting");
lock.wait();
System.out.println("thread2 is awake");
} catch (InterruptedException e) {
}
}
}).start();
}
}

上面的代码运行结果如下

1
2
3
4
5
6
thread1 is ready
thread2 is ready
thread2 is waiting
thread1 is notify,but not exit synchronized
thread 1 is exit synchronized
thread2 is awake

看到这里你会发现平平无奇,好像也没什么特殊的事情,但是如果深入分析下,就会发现以下的问题

  1. 为何调用wait或者notify一定要加synchronized,不加行不行?
  2. 调用notify/notifyAll后等待中的线程会立刻唤醒吗?
  3. 调用notify/notifyAll是随机从等待线程队列中取一个或者按某种规律取一个来执行?
  4. wait的线程是否会影响系统性能?

针对上面的问题,我们逐个分析下

为何调用wait或者notify一定要加synchronized,不加行不行?

如果你不加,你会得到下面的异常

1
Exception in thread "main" java.lang.IllegalMonitorStateException

JVM源代码中首先会检查当前线程是否持有锁,如果没有持有则抛出异常

其次为什么要加,也有比较广泛的讨论,首先wait/notify是为了线程间通信的,为了这个通信过程不被打断,需要保证wait/notify这个整体代码块的原子性,所以需要通过synchronized来加锁。

调用notify/notifyAll后等待中的线程会立刻唤醒吗?

hotspot真正的实现是退出同步代码块的时候才会去真正唤醒对应的线程,不过这个也是个默认策略,也可以改的,在notify之后立马唤醒相关线程。 这个也可从jdk源代码的objectMonitor类objectMonitor::notify方法中看到.在调用notify时的默认策略是Policy == 2(这个值是源码中的初值,可以通过-XX:SyncKnobs来设置)

其实对于Policy(1、2、3、4)都是将objectMonitor的ObjectWaiter集合中取出一个等待线程,放入到_EntryList(blocked线程集合,可以参与下次抢锁),只是放入_EntryList的策略不一样,体现为唤醒wait线程的规则不一样。

对于默认策略notify在将一个等待线程放入阻塞线程集合之后就退出,因为同步块还没有执行完monitorexit,锁其实还未释放,所以在打印出“thread1 is exit synchronized!”的时候,thread2线程还是blocked状态(因为thread1还没有退出同步块)。

这里可以发现,对于不在Policy中的情况,会直接将一个ObjectWaiter进行unpark唤醒操作,但是被唤醒的线程是否立即获取到了锁呢?答案是否定的。

调用notify/notifyAll是随机从等待线程队列中取一个或者按某种规律取一个来执行?

我们自己实现可能一个for循环就搞定了,不过在jvm里实现没这么简单,而是借助了monitor_exit,上面我提到了当某个线程从wait状态恢复出来的时候,要先获取锁,然后再退出同步块,所以notifyAll的实现是调用notify的线程在退出其同步块的时候唤醒起最后一个进入wait状态的线程,然后这个线程退出同步块的时候继续唤醒其倒数第二个进入wait状态的线程,依次类推,同样这是一个策略的问题,jvm里提供了挨个直接唤醒线程的参数,这里要分情况:

  1. 如果是通过notify来唤起的线程,那先进入wait的线程会先被唤起来
  2. 如果是通过nootifyAll唤起的线程,默认情况是最后进入的会先被唤起来,即LIFO的策略

wait的线程是否会影响系统性能?

这个或许是大家比较关心的话题,因为关乎系统性能问题,wait/nofity是通过jvm里的park/unpark机制来实现的,在linux下这种机制又是通过pthread_cond_wait/pthread_cond_signal来玩的,因此当线程进入到wait状态的时候其实是会放弃cpu的,也就是说这类线程是不会占用cpu资源,也不会影响系统加载。

什么是监视器(monitor)

Java中每一个对象都可以成为一个监视器(Monitor), 该Monitor由一个锁(lock), 一个等待队列(waiting queue ), 一个入口队列( entry queue)组成. 对于一个对象的方法, 如果没有synchonized关键字, 该方法可以被任意数量的线程,在任意时刻调用。 对于添加了synchronized关键字的方法,任意时刻只能被唯一的一个获得了对象实例锁的线程调用。

进入区(Entry Set): 表示线程通过 synchronized要求获得对象锁,如果获取到了,则成为拥有者,如果没有获取到在在进入区等待,直到其他线程释放锁之后再去竞争(谁获取到则根据)

拥有者(Owner): 表示线程获取到了对象锁,可以执行synchronized包围的代码了

等待区(Wait Set): 表示线程调用了wait方法,此时释放了持有的对象锁,等待被唤醒(谁被唤醒取得监视器锁由jvm决定)

关于sleep

它是一个静态方法,一般的调用方式是Thread.sleep(2000),表示让当前线程休眠2000ms,并不会让出监视器,这一点需要注意。

对比 sleep():

  • 原理不同:sleep() 方法是属于 Thread 类,是线程用来控制自身流程的,使此线程暂停执行一段时间而把执行机会让给其他线程;wait() 方法属于 Object 类,用于线程间通信
  • 锁的处理机制不同:调用 sleep() 方法的过程中,线程不会释放对象锁,当调用 wait() 方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池(不释放锁其他线程怎么抢占到锁执行唤醒操作),但是都会释放 CPU
  • 使用区域不同:wait() 方法必须放在同步控制方法和同步代码块(先获取锁)中使用,sleep() 方法则可以放在任何地方使用