synchronized原理是什么?synchronized到底有什么特点,synchronized的锁策略是什么,是怎么变化的呢?本篇文章总结出, Synchronized 具有以下特性,加锁工作过程,锁消除,锁粗化,Callable接口的用法,JUC的常见问题~~
目录
四、JUC(java.util.concurrent) 的常见类
7.2 为什么有了 synchronized 还需要 juc 下的 lock?
一、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 操作