- 博主简介:想进大厂的打工人
- 博主主页:@xyk:
- 所属专栏: JavaEE初阶
synchronized原理是什么?synchronized到底有什么特点,synchronized的锁策略是什么,是怎么变化的呢?本篇文章总结出, Synchronized 具有以下特性,加锁工作过程,锁消除,锁粗化,Callable接口的用法,JUC的常见问题~~
目录
文章目录
一、synchronized的基本特点
二、synchronized的关键策略——锁升级
三、其他的优化操作
3.1 锁消除
3.2 锁粗化
四、JUC(java.util.concurrent) 的常见类
4.1 Callable的用法
4.2 ReentrantLock
4.3 如何选择使用哪个锁?
五、信号量 Semaphore
六、CountDownLatch
七、相关面试题
7.1 线程同步的方式有哪些?
7.2 为什么有了 synchronized 还需要 juc 下的 lock?
7.3 AtomicInteger 的实现原理是什么?
7.4 信号量听说过么?之前都用在过哪些场景下?
一、synchronized的基本特点
结合上次写的锁策略文章,我们就可以总结出,synchronized具有一下特性:
1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁
3. 实现轻量级锁的时候大概率用到的自旋锁策略,重量级锁基于挂起等待锁实现
4. 是一种不公平锁
5. 是一种可重入锁
6. 不是读写锁
二、synchronized的关键策略——锁升级
synchronized工作过程,具体讨论下synchronized里面都做了什么:
synchronized的关键策略:锁升级
刚开始加锁,第一个尝试加锁的线程, 优先进入偏向锁状态,那么什么是偏向锁?
偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程;
如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销),
如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别
当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态
偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.
但是该做的标记还是得做的, 否则无法区分何时需要真正加锁
我来举个例子再理解一下:
假设,有个妹子看上了个小哥哥,主动出击,此时有个问题,如果把他拿下了,让他做男票了~~过一段时间,万一腻了的话,想换个男朋友,成本就高了!!
更高效的方法,就是不和他挑明关系,“无情侣之名,有情侣之实”,这样就很轻量,很高效,如果什么时候想换人,直接给他说,咱们只是普通朋友,此时更换男朋友的成本就低了~~
假设妹子在和小哥哥暧昧的过程中,别的妹子,听说这个小哥哥单身,也想过来试试,此时妹子就发现,存在潜在威胁~~于是就立即和小哥哥挑明关系,并且朋友圈宣誓!!此时别的妹子,就只能阻塞等待了~~
偏向锁,只是先让线程针对锁有个标记,如果没有竞争就不加锁,有竞争再升级为真的锁(轻量级锁),此时别的线程只能等待,既保证了效率,又保证了线程安全!!
自旋锁 :
随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁)
遇到锁竞争,就是自旋锁(轻量级锁),速度是快,但是消耗大量cpu,自旋的时候,cpu 是快速空转的~~
此处的轻量级锁就是通过 CAS 来实现
- 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
- 如果更新成功, 则认为加锁成功
- 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU)
如果当前锁竞争比较激烈,比如,10个线程,竞争1个锁,1个竞争上,另外9个等待~~
既然这么多都在自选,cpu的消耗就非常大,既然如此,就升级成重量级锁,在内核里进行阻塞等待(意味着线程要暂时放弃cpu,由内核进行后续调度)
三、其他的优化操作
3.1 锁消除
非必要不加锁!!
编译器+JVM 判断当前代码是否是多线程执行/锁是否可消除. 如果可以, 就直接再编译过程中自动把锁消除~
有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加
锁解锁操作是没有必要的, 白白浪费了一些资源开销;
此时编译器就会自动把锁消除掉~~
不是说,写了synchronized就一定线程安全,也不是不写synchronized就一定线程安全
3.2 锁粗化
跟锁的粒度有关系,那么什么是锁粒度?
锁的粒度,就是synchronized代码块,包含代码的多少(代码越多,粒度越粗,代码越少,粒度越细)
一般情况下,写代码希望锁的粒度更小一点(串行执行的代码少,并行执行的代码多)
但是如果某个场景,要频繁加锁/解锁,此时编译器就可以把这个操作优化成一个更粗粒度的锁
每次加锁解锁,都要有开销~~尤其是释放锁之后,重新加锁,还需要重新竞争~~
举个例子立即锁粗化:
滑稽老哥当了领导, 给下属交代工作任务:
方式一:
- 打电话, 交代任务1, 挂电话.
- 打电话, 交代任务2, 挂电话.
- 打电话, 交代任务3, 挂电话.
方式二:
- 打电话, 交代任务1, 任务2, 任务3, 挂电话.
显然, 方式二是更高效的方案
可以看到, synchronized 的策略是比价复杂的, 在背后做了很多事情, 目的为了让程序猿哪怕啥都不懂,也不至于写出特别慢的程序
JVM 开发者为了 Java 程序猿操碎了心
四、JUC(java.util.concurrent) 的常见类
4.1 Callable的用法
Callable 是一个 interface . 相当于把线程封装了一个 "返回值". 方便程序猿借助多线程的方式计算结果;
Callable的用法非常类似Runnable,描述了一个任务~~一个线程要做什么
Runnable通过run方法描述,返回类型是void
Callable通过call方法,有返回值
代码示例:创建线程计算 1 + 2 + 3 + ... + 1000
- 创建一个匿名内部类,实现 Callable 接口. Callable 带有泛型参数.泛型参数表示返回值的类型.
- 重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果.
- 把 callable 实例使用 FutureTask 包装一下.
- 创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
- 在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果.
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int sum = 0;
for (int i = 1; i <= 1000; i++) {
sum += i;
}
return sum;
}
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
int result = futureTask.get();
System.out.println(result);
futureTask包含了一个get方法,就是用来取结果的方法!!
如果保证,调用get的时候,t线程的call方法是执行完毕了呢?
get方法和join方法类似,都是会阻塞等待!!
4.2 ReentrantLock
synchronized 关键字, 是基于代码块的方式来控制加锁解锁的
可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全
ReentrantLock 也是可重入锁. "Reentrant" 这个单词的原意就是 "可重入“
ReentrantLock 则是提供了lock和unlock独立的方法,来进行加锁解锁~~
ReentrantLock 的用法:
lock(): 加锁, 如果获取不到锁就死等.
trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
unlock(): 解锁ReentrantLock lock = new ReentrantLock(); ----------------------------------------- lock.lock(); try { // working } finally { lock.unlock() }
虽然大部分情况下,使用synchronized就足够了,ReentrantLock也是一个重要的补充!!
ReentrantLock 和 synchronized 的区别:
1.synchronized只是加锁和解锁,加锁的时候如果发现锁被占用,只能阻塞等待!!
ReentrantLock还提供一个方法tryLock方法:
如果加锁成功,没什么特殊的,就是普通的加锁
如果加锁失败,不会阻塞,直接返回false!!
2.synchronized是一个非公平锁(概率均等,不遵守先来后到)
ReentrantLock提供了公平和非公平俩种工作模式(在构造方法中,传入true开启公平锁)
3.synchronized搭配wait notify进行唤醒等待,如果多个线程wait同一个对象,notify随机唤醒一个线程
ReentrantLock则是搭配Condition这个类,这个类也能起到等待通知,可能功能更强大!!
4.3 如何选择使用哪个锁?
锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
如果需要使用公平锁, 使用 ReentrantLock
五、信号量 Semaphore
信号量, 用来表示 "可用资源的个数". 本质上就是一个计数器
理解信号量
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)
当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用
代码示例:
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) {
e.printStackTrace();
}
}
};
for (int i = 0; i < 20; i++) {
Thread t = new Thread(runnable);
t.start();
}
六、CountDownLatch
同时等待 N 个任务执行结束
好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩
构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了
七、相关面试题
7.1 线程同步的方式有哪些?
synchronized, ReentrantLock, Semaphore 等都可以用于线程同步
7.2 为什么有了 synchronized 还需要 juc 下的 lock?
以 juc 的 ReentrantLock 为例,
- synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活,
- synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
- synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个true 开启公平锁模式.
- synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程
7.3 AtomicInteger 的实现原理是什么?
class AtomicInteger {
private int value;
public int getAndIncrement() {
int oldValue = value;
while ( CAS(value, oldValue, oldValue+1) != true) {
oldValue = value;
}
return oldValue;
}
}
7.4 信号量听说过么?之前都用在过哪些场景下?
- 信号量, 用来表示 "可用资源的个数". 本质上就是一个计数器.
- 使用信号量可以实现 "共享锁", 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待,直到前面的线程执行了 V 操作