三、synchronized 原理
结合上面对锁策略的描述,就可以对 synchronized 先做出一个直观的认识:
① synchronized 在开始使用的时候,它是乐观锁,如果发现锁冲突概率比较高,就会自动转成悲观锁。
② synchronized 不是读写锁。
③ synchronized 开始的时候是轻量级锁,如果锁被持有的时间较长,或者锁的冲突概率较高,就会升级成重量级锁。
④ synchronized 是一个非公平锁。
⑤ synchronized 是一个可重入锁。
⑥ synchronized 为轻量级锁的时候,大概率是一个自旋锁,为重量级锁的时候大概率是一个挂起等待锁。
1. 锁升级
锁升级的过程:
偏向锁是使第一个尝试加锁的线程,优先进入偏向锁状态。偏向锁本质上相当于 " 延迟加锁 ", 能不加锁就不加锁,尽量来避免不必要的加锁开销。但是该做的标记还是得做的,否则无法区分何时需要真正加锁。它起到了一个预判的作用。
偏向锁不是真的 " 加锁 ",只是给对象头中做一个 " 偏向锁的标记 ",记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁,那么此次操作就不会加锁,从而避免了加锁解锁的开销;如果后续有其他线程来竞争该锁,那就取消原来的偏向锁状态,进入一般的轻量级锁状态。
举个例子:这就像武侠小说中的 " 投石问路 " 一样,一个人在机关重重的地方冒险,当他进入关卡之前,就要先扔一块石头探探路,如果没有陷阱,那么就可以放心地闯关;如果有机关陷阱,那么再想办法应对。
在偏向锁标记之后,如果后续有其他线程来竞争该锁,那就取消原来的偏向锁状态,进入一般的轻量级锁状态。此处的轻量级锁,就是基于 CAS 实现的自旋锁,是属于完全在用户态完成的操作。因此这里面不涉及到内核态用户态的切换,也不涉及到线程的阻塞等待和调度,只是多费了一些CPU而已。然而轻量级锁却能保证更高效地获取到锁 ( 线程1 一释放锁,线程2 就能立即获取到 锁,这就体现了锁的竞争并不激烈 )
但如果在锁冲突的概率较大,锁竞争比较激烈的场景下,那么锁还会进一步地膨胀成重量级锁,如果锁冲突的概率太大了,使用轻量级自旋锁时,就会浪费大量的CPU,在等待的时候是,CPU 空转的。那么此时使用更重量的挂起等待锁,就可以解决这个问题。对于挂起等待锁来说,当锁等待的过程中,会进行释放 CPU,不过代价就是会引入了线程阻塞和调度开销。
综上所述,锁的每个状态、每个策略都有自身的优缺点,实际上没有好坏之分,不同的应用场景下,只有合适的才是最好的。而在我上面所说的锁状态变化的过程中,synchronized 是会自适应锁升级的整个过程。
2. 拓展
synchronized 加锁实际上是一个比较复杂的东西,它的发展也是经历了很多年,都是通过一些计算机大佬一步步的修改出来的,而早期的 synchronized 其实就是一个单纯的互斥锁的封装,到了Java8 时代,其实 synchronized 就已经比较的完善了 ( 1.8 )
而像我们刚刚说的锁能升级过程( 从无锁 => 偏向锁 => 轻量级锁 =>重量级锁 )
那么能不能降级呢?
例如一个场景,平时的时候请求很低,锁冲突的概率很低,以轻量级锁状态工作。突然遇到了一个请求峰值,从而使锁冲突的概率变高了,最后膨胀成了重量级锁。过了一会儿,峰值过去了,请求又少了…这个时候锁冲突的概率又降下去了,此时 synchronized 的加锁机制是否会从重量级锁再降回轻量级锁呢?
我们上面提到 synchronized 的加锁机制实际上和 JVM 版本有关,在 1.8 版本的时候,趋于完善,但在这个版本( 企业广泛使用的版本 1.8 ),即 Oracle 官方的1.8 版本并没有实现锁降级的过程。未来在别的版本是否添加降级机制,有待观察。
3. 锁销除
锁消除,其实就是编译器和 JVM 自行判定一下, 看看当前这个代码是否真的需要加锁。
举个例子:如果当前程序只有一个线程在跑,此时就算写了加锁,编译器也会自动地把锁给去除,不会真的进行加锁。
4. 锁的粒度和锁粗化
锁的粒度:锁的粒度粗细即 synchronized 代码块中包含了多少代码。如果包含的代码相对多,我们认为锁的粒度粗;如果包含的代码相对少,我们认为锁的粒度细。
那么,程序员在写代码的时候,到底是锁的粒度较粗还是较细好,这需要根据场景判断。
如果锁的粒度细,意味着代码持有锁的时间就短,就能更快释放,这样一来,致使其他线程冲突的概率就更小。
而在下图中,锁的粒度粗较好一些,这提高了效率、减小了加锁解锁的开销。
锁的粗化:锁的粗化就是上图中对应的代码1 到 代码2 之间的转换,如果程序写出的代码的一段逻辑中如果出现多次加锁解锁,编译器 和 JVM 会自动进行锁的粗化。
5. 总结 synchronized
总的来说,synchronized 的策略是比较复杂的,程序员在编译代码的背后,Java 底层实际做了很多事情,目的为了让程序员哪怕啥都不懂,也不至于写出特别拉跨的程序。JVM 开发者为了 Java 程序员真是操碎了心。
四、Callable 接口
Callable 是一个接口,它相当于把线程封装了一个 " 返回值 "。方便程序员在使用的时候,借助多线程的方式计算结果,它也是用来创建线程的。
我们计算 1 + 2 + … +100 的结果
程序清单3:
public class Test { static class Result { public int sum = 0; } public static void main(String[] args) throws InterruptedException { Result result = new Result(); Thread t = new Thread() { @Override public void run() { int sum = 0; for (int i = 0; i <= 100; i++) { sum = sum + i; } result.sum = sum; } }; t.start(); t.join(); //此处我们希望正在运行的线程能够在主线程获取到 //为了解决这个问题,就需要引入一个辅助的类 //我们目标很明确,就是要等到 t 线程运行完了,主线程拿到 sum 即可 System.out.println(result.sum); } }
输出结果:
在程序清单3 中,由于 t 线程与主线程之间是并发执行的,所以我们需要明确:等 t 线程执行全部完了,主线程才能拿到最终的结果!
程序清单4:利用 Callable 接口
import java.util.concurrent.Callable; import java.util.concurrent.ExecutionException; import java.util.concurrent.FutureTask; public class Test { public static void main(String[] args) throws ExecutionException, InterruptedException { Callable<Integer> callable = new Callable<Integer>() { @Override public Integer call() throws Exception { int sum = 0; for (int i = 0; i <= 100; i++) { sum += i; } return sum; } }; //由于 Thread 类不能直接接受一个 callable 的参数,所以就需要一个辅助类来包装一下 //而 FutureTask 就是这个类 FutureTask<Integer> futureTask = new FutureTask<>(callable); Thread t = new Thread(futureTask); t.start(); //尝试在主线程获取结果,如果 Callable 中的代码还没全部执行完,就不会返回 //在主线程中调用 futureTask.get() 能够阻塞等待,直到正在执行的线程运算结果完毕 Integer result = futureTask.get(); System.out.println(result); } }
输出结果:
总结:
Callable 和 Runnable 是同一级别的,但却是相对的,它在于都是描述一个 “任务”。
Callable 接口描述的是带有返回值的任务,而 Runnable 接口描述的是不带返回值的任务。
Callable 接口通常需要搭配 FutureTask 类来使用,FutureTask 用来保存 Callable 的返回结果,因为 Callable 往往是在另一个线程中执行的,啥时候执行完并不确定。而 FutureTask 就可以负责等待整个当前线程执行的过程,最后通过 get 方法返回执行完的结果即可。
理解 FutureTask
举个例子。假设你和朋友去吃麻辣烫,当餐点好后,后厨就开始做了,同时前台会给你一张 " 小票 ",这个小票就是FutureTask,后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没。
五、ReentrantLock 类
ReentrantLock :可重入锁
re-entrant a. 可重入的
程序清单5:
在程序清单5 中,这是我们之前使用多线程,让 count 自增的经典案例,t1 线程和 t2 线程各自增加 5w 次,我们尝试利用 ReentrantLock 类来加锁。
import java.util.concurrent.locks.ReentrantLock; public class Test { static class Counter { public int count = 0; public ReentrantLock locker = new ReentrantLock(); //让 count 变量自增 public void increase() { locker.lock(); count++; locker.unlock(); } } static Counter counter = new Counter(); public static void main(String[] args) { //线程1 自增 5w 次 Thread t1 = new Thread() { @Override public void run() { for (int i = 0; i < 50000; i++) { counter.increase(); } } }; //线程2 自增 5w 次 Thread t2 = new Thread() { @Override public void run() { for (int i = 0; i < 50000; i++) { counter.increase(); } } }; t1.start(); t2.start(); try { t1.join(); t2.join(); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(counter.count); } }
输出结果:
ReentrantLock 类提供了加锁功能,它与 synchronized 加锁机制有联系,也有区别。
ReentrantLock 把加锁和解锁操作拆分开了,这种风格的代码,是常见写法,很多编程语言都是这样的;而 synchronized 是较为流行的写法,使用起来也较为简单。
ReentrantLock 与 synchronized 加锁的区别与联系
① synchronized 是一个关键字,是 JVM 内部实现的 ( 大概率是基于 C++ 实现 )
ReentrantLock 是标准库的一个类,它是在 JVM 外实现的 ( 基于 Java 实现 )
② ReentrantLock 把加锁和解锁拆成两个方法,这使得代码更加灵活,比如把加锁和解锁的代码分别放到两个方法中,而 synchronized 却做不到,因为被 synchronized 包裹的代码,出了代码末尾,才算被解锁。
③ ReentrantLock 类提供了 synchronized 内部没有的机制。它除了 lock 加锁 和 unlock 解锁 之外,还提供了 tryLock 方法。对于 lock 方法来说,如果尝试加锁失败,就会阻塞等待;对于 tryLock 方法来说,如果尝试加锁失败,就直接返回出错,不会阻塞等待,所以这就降低了浪费 CPU 的资源。
④ synchronized 是一个非公平锁,而 ReentrantLock 既支持公平锁,也支持非公平锁。
⑤ ReentrantLock 提供了比 synchronized 更强大的等待唤醒机制,synchronized 是搭配 wait 和 notify 方法,而 ReentrantLock 则是搭配了另外一个 Condition 类来完成等待唤醒。notify
方法是随机唤醒一个线程,而 Condition 类可以显示指定唤醒哪个等待的线程。
综上所述,大部分情况下,我们依然使用 synchronized 关键字来加锁解锁,这更熟悉,而且它用起来也比较方便;但某些特定场景下,我们就需要使用 ReentrantLock 类的一些加锁机制下的功能。
六、信号量
信号量,用来表示 " 可用资源的个数 ",本质上就是一个计数器。
举个例子:我们可以把信号量想象成是停车场的空车位,当前有车位 100 个,表示有 100 个可用资源。当有车开进去的时候,就相当于申请一个可用资源,可用车位就 -1 ( 这个操作对应信号量的 P 操作 ),当有车开出来的时候,就相当于释放一个可用资源,可用车位就 +1 ( 这个操作对应信号量的 V 操作 )。如果计数器的值已经为 0 了,还尝试申请资源,就会阻塞等待,直到有其他线程释放资源,这就相当于车库的车都停满了,门卫把停车场的升降杆降下来了,不让你进去。那么信号量的值不会为负数。
Semaphore 的 PV 操作中的加减计数器操作都是原子的,可以在多线程环境下直接使用。
P 操作对应 acquire 方法,表示申请资源。 V 操作对应 release 方法,表示释放资源。
程序清单6:
import java.util.concurrent.Semaphore; public class Test { public static void main(String[] args) { //创建 Semaphore 的实例, 初始化为 3, 表示有 3 个可用资源 //想象成停车场只有三个空位子 Semaphore semaphore = new Semaphore(3); Runnable runnable = new Runnable() { @Override public void run() { try { //1. 先尝试申请资源 System.out.println("尝试申请资源"); semaphore.acquire(); //2. 申请到了之后,睡眠 1 秒 System.out.println("申请成功!"); Thread.sleep(1000); //3. 再释放资源 semaphore.release(); System.out.println("完毕"); } catch (InterruptedException e) { e.printStackTrace(); } } }; //创建 6个线程,让这 6个线程分别尝试获取资源 for (int i = 0; i < 6; i++) { Thread t = new Thread(runnable); t.start(); } } }
输出结果:
在程序清单6 中,我们创建信号量的值为3,在输出结果中我们可以看到,只有当线程释放资源的时候,其他线程才能申请到资源。这就好比停车,把它想象成停车场只有三个位置,而现在有六辆车来争夺资源,也就是争夺空车位,而当前三辆车争夺到这三个位置之后,其他的车就要阻塞等待,直到车主将车开出停车场,才有空位置。
信号量对比 synchronized
在使用 synchronized 的时候,我们都知道,当为一个代码块上锁的时候,所有的线程都得同时竞争这把锁,之后我们就能控制某个线程将这个代码块全部执行完,这个线程在还未执行完之前,其他线程就得等待,相当于只有一个空车位。显然,锁本身只能控制一个资源。
而信号量却可以控制多个资源,它可以让多个线程同时争夺多个资源,而资源数可以为我们所控制,所以说,信号量其实就是 synchronized 的进阶版本。
那么当我们将信号量的可用资源数换成1 了,那它实际上与加锁解锁的机制没什么区别,同时这里的信号量也被成为 " 二元信号量 "。这就相当于:多个线程竞争一把锁,即多个车竞争一个空车位。