本篇博客给大家带来的是多线程中synchronize的实现原理和JUC(java.util.concurrent) 常见类的相关知识点.
🐎文章专栏: JavaEE初阶
🚀若有问题 评论区见
❤ 欢迎大家点赞 评论 收藏 分享
如果你不知道分享给谁,那就分享给薯条.
你们的支持是我不断创作的动力 .
王子,公主请阅🚀
要开心
要快乐
顺便进步
1. synchronized原理
synchronized的基本特点(只考虑JDK1.8):
1. 一开始还是乐观锁,如果锁冲突频繁,就转换为悲观锁.
2. 开始是轻量级锁,如果锁被持有时间较长,就转换成重量级锁.
3. 实现轻量级锁时大概率用自旋锁策略.
4. 是一种不公平锁.
5. 是一种可重入锁.
6. 不是读写锁.
1.1 加锁工作过程
JVM将synchronized锁分为无锁,偏向锁,轻量级锁,重量级锁. 会根据实际情况, 进行一次升级.
1.1.1 偏向锁
第一个尝试加锁的线程, 优先进入偏向锁状态.
偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程.
如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.
偏向锁本质上相当于 “延迟加锁” . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.
举个例子来理解偏向锁:
假设我是一个长的好看又有才华的小姐姐, 某一次吃饭,我遇见了一个非常符合我审美的小哥哥,巧的是他还主动来加我微信,后面我就约他出来玩,一举将他拿下.
成为情侣一段时间后,我发现我腻歪了,想分手了, 此时我就得给他找事,比如: 看到他跟女生聊天就问"你是不是不爱我了",有点小事就跟他吵架,把小事上升到分手.
上述纯属段子. 不难发现,成为情侣后,若是想分手,成本非常的高.
重点来了, 偏向锁是我约小哥哥出来玩,虽然走得很近,但是不表白,这样一来想脱身就非常容易. 这就是偏向锁的加锁过程. 先标记,不上锁.
1.1.2 轻量级锁
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).此处的轻量级锁就是通过 CAS 来实现
通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
如果更新成功, 则认为加锁成功
如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源.
因此此处的自旋不会一直持续进行, 而是达到一定的时间/重试次数, 就不再自旋了.也就是所谓的 “自适应”
1.1.3 重量级锁
如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁此处的重量级锁就是指用到内核提供的 mutex .
执行加锁操作, 先进入内核态.
① 在内核态判定当前锁是否已经被占用
② 如果该锁没有占用, 则加锁成功, 并切换回用户态.
③ 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起等待被操作系统唤醒.
④ 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁.
1.2 锁消除
编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除.
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解
锁操作是没有必要的, 白浪费了一些资源开销.
锁消除是编译器在编译过程出发的,还没到运行时.
偏向锁是运行时的事情,根据多线程调度情况的不同来变化.
1.3 锁粗化
一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.
锁的粒度: 粗和细
synchronized里,代码越多,就认为锁的粒度越粗,代码越少锁的粒度越细.
实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁.
粒度细的时候,能够并发执行的逻辑更多,更有利于充分利用多核 CPU 资源
如果粒度细的锁,被反复进行加锁解锁,可能实际效果还不如粒度粗的锁(涉及到反复的锁竞争)
例如: 给领导汇报工作,
假设领导给你安排了三个工作
完成之后,就可以给领导汇报工作了.
打第一个电话汇报一个工作,挂断.
再打第二个电话汇报第二个工作,挂断.
最后打第三个电话汇报第三个工作.
这样,领导骂不骂你,先不论. 但从效率上来讲: 不如直接一个电话全部汇报完.
1.4 相关面试题
1. 什么是偏向锁?
偏向锁不是真的加锁, 而是在锁的对象头中记录一个标记(记录该锁所属的线程). 如果没有其他线程
参与竞争锁, 那么就不会真正执行加锁操作, 从而降低程序开销. 一旦真的涉及到其他的线程竞争, 再取消偏向锁状态, 进入轻量级锁状态.
2. synchronized 实现原理是什么?
标题一的所有内容都是.
2.JUC(java.util.concurrent) 的常见类
2.1 Callable接口
Callable 是一个 interface,也是创建线程的一种方式,跟Runnable类似,相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算结果.
不使用Callable的代码示例:
① 创建一个类 Result , 包含一个 sum 表示最终结果, lock 表示线程同步使用的锁对象.
② main 方法中先创建 Result 实例, 然后创建⼀个线程 t. 在线程内部计算 1 + 2 + 3 + … + 1000.
③ 主线程同时使用 wait 等待线程 t 计算结束. (注意, 如果执行到 wait 之前, 线程 t 已经计算完了, 就不必等待了).
④ 当线程 t 计算完毕后, 通过 notify 唤醒主线程, 主线程再打印结果.
public class 不使用Callable {
static class Result{
public int sum = 0;
public Object lock = new Object();
}
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 <= 1000; i++) {
sum += i;
}
synchronized (result.lock) {
result.sum = sum;
result.lock.notify();
}
}
};
t.start();
synchronized (result.lock) {
while(result.sum == 0) {
result.lock.wait();
}
}
System.out.println(result.sum);
}
}
可以看到, 上述代码需要一个辅助类 Result, 还需要使用一系列的加锁和 wait notify 操作, 代码复杂,容易出错.
使用Callable的代码示例:
public class 使用Callable {
public static void main(String[] args) throws ExecutionException, InterruptedException {
//定义一个callable任务与runnable任务类似.
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 0; i <= 1000; i++) {
sum += i;
}
return sum;
}
};
//不能将callable直接new成thread对象.
//Thread t = new Thread(callable);//报错
//正确的做法是: 借助FutureTask,以FutureTask为中介new Thread
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
//这里调用get()方法就能获取到callable里面返回的结果
//由于线程是并发执行的,执行到get的时候,t线程可能还没执行完,这时get就会阻塞等待.
System.out.println(futureTask.get());
}
}
使用 Callable 和 FutureTask 之后, 代码简化了很多, 也不必手动写线程同步代码了.
理解Callable
Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务, Runnable描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的共作.
理解 FutureTask
想象去吃麻辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你一张 “小票” . 这个小票就是
FutureTask. 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没.
2.2 ReentrantLock
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全
ReentrantLock 的用法:
① lock(): 加锁, 如果获取不到锁就死等.
② trylock(超时时间): 加锁, 如果获取不到锁, 等待⼀定的时间之后就放弃加锁.
③ unlock(): 解锁
ReentrantLock lock = new ReentrantLock();
-----------------------------------------
lock.lock();
try {
// working
} finally {
lock.unlock()
}
ReentrantLock和 synchronized 的区别:
① synchronized 是⼀个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准
库的一个类, 在 JVM 外实现的(基于 Java 实现).
② synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏unlock.
③ synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
④ synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
// ReentrantLock 的构造⽅法
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
⑤ ReentrantLock 更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.
如何选择使用哪个锁?
① 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
② 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
③ 如果需要使用公平锁, 使用ReentrantLock.
2.3 原子类
原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。
原子类有以下几个:
① AtomicBoolean
② AtomicInteger
③ AtomicIntegerArray
④ AtomicLong
⑤ AtomicReference
⑥ AtomicStampedReference
以 AtomicInteger 举例,常见方法有:
addAndGet(int delta); i += delta;
decrementAndGet(); --i;
getAndDecrement(); i--;
incrementAndGet(); ++i;
getAndIncrement(); i++;
2.4 线程池
虽然创建销毁线程比创建销毁进程更轻量, 但是在频繁创建销毁线程的时候还是比较低效.
线程池就是为了解决这个问题. 如果某个线程不再使用了, 并不是真正把线程销毁, 而是放到⼀个 “池子” 中, 下次如果需要用到线程就直接从池子中取, 不必通过系统来创建了.
ExecutorService 和 Executors
ExecutorService 表示一个线程池实例.
• Executors 是一个工厂类, 能够创建出几种不同风格的线程池.
• ExecutorService 的 submit 方法能够向线程池中提交若干个任务
public class Demo28 {
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(10);
service.submit(new Runnable() {
@Override
public void run() {
System.out.println("hello");
}
});
}
}
Executors 创建线程池的几种方式
① newFixedThreadPool: 创建固定线程数的线程池
② newCachedThreadPool: 创建线程数目动态增长的线程池.
③ newSingleThreadExecutor: 创建只包含单个线程的线程池.
④ newScheduledThreadPool: 设定延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.
⑤ Executors 本质上是 ThreadPoolExecutor 类的封装.
ThreadPoolExecutor
ThreadPoolExecutor 提供了更多的可选参数, 可以进一步细化线程池行为的设定.ThreadPoolExecutor 的构造方法
理解 ThreadPoolExecutor 构造方法的参数 把创建一个线程池想象成开个公司. 每个员工相当于一个线程.
① corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
② maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
③ keepAliveTime: 临时工允许的空闲时间.
④ unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
⑤ workQueue: 传递任务的阻塞队列
⑥ threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
⑦ RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
⑧ AbortPolicy(): 超过负荷, 直接抛出异常.
⑨ CallerRunsPolicy(): 调用者负责处理
⑩ DiscardOldestPolicy(): 丢弃队列中最老的任务.
11. DiscardPolicy(): 丢弃新来的任务.
2.5 信号量Semaphore
信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
举个例子来理解信号量:
停车场门口通常会有展示牌: 剩于10个车位,表示有10个可用资源.
当有一辆车进去停,车位-1(相当于信号量的P操作).
当有 一辆车开出来,车位+1(相当于信号量的V操作).
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用.
代码示例:
① 创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源.
② acquire 方法表示申请资源(P操作) .
③ release 方法表示释放资源(V操作).
④ 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果.
public class Semaphore示例 {
public static void main(String[] args) {
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
@Override
public void run() {
try {
System.out.println("申请资源");
semaphore.acquire();
System.out.println("获取资源");
Thread.sleep(1000);
System.out.println("释放资源");
semaphore.release();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
};
for (int i = 0; i < 20; i++) {
Thread t = new Thread(runnable);
t.start();
}
}
}
2.6 CountDownLach
CountDownLach同时等待 N 个任务执行结束. 好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。
① 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
② 每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.
③ 主线程中使用 countDownLatch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了
public class Demo33 {
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(10);
for (int i = 0; i < 10; i++) {
int id = i;
Thread t = new Thread(() -> {
//System.out.println("thread "+i);//不能直接用 i ,涉及到变量捕获的知识.
System.out.println("thread "+id);
try {
Thread.sleep(1500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
countDownLatch.countDown();
});
t.start();
}
countDownLatch.await();
System.out.println("所有任务都执行完了.");
}
}
2.7 相关面试题
1. 线程同步的方式有哪些?
synchronized, ReentrantLock, Semaphore 等都可以用于线程同步.
2. 为什么有了 synchronized 还需要 juc 下的 lock?
以 juc 的 ReentrantLock 为例,
① synchronized 使用时不需要手动释放锁. ReentrantLock 使⽤时需要手动释放. 使用起来更灵活,
② synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
③ synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式.
④ synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是⼀个随机等待的线程.
⑤ ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.
3. AtomicInteger 的实现原理是什么?
详细看我上一篇文章: 【JavaEE初阶】多线程重点知识以及常考的面试题-多线程进阶(一) 中CAS实现原子类那一部分知识就是答案
本篇博客到这里就结束啦, 感谢观看 ❤❤❤
🐎期待与你的下一次相遇😊😊😊