并发编程(学习笔记-共享模型之管程)-part3
Posted LL.LEBRON
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了并发编程(学习笔记-共享模型之管程)-part3相关的知识,希望对你有一定的参考价值。
文章目录
并发编程-共享模型之管程-3
本文章视频指路👉黑马程序员-并发编程
1.共享带来的问题
1-1 临界区 Critical Section
- 一个程序运行多个线程本身是没有问题的
- 问题出在多个线程访问共享资源
- 多个线程读共享资源其实也没有问题
- 多个线程对共享资源读写操作时发生指令交错,就会出现问题
- 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
例如,下面代码中的临界区:
static int counter = 0;
static void increment()
// 临界区
{
counter++;
}
static void decrement()
// 临界区
{
counter--;
}
1-2 竞态条件 Race Condition
多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
2.synchronized 解决方案
2-1 解决方案
为了避免临界区的竞态条件发生,有多种手段可以达到目的。
- 阻塞式的解决方案:synchronized,Lock
- 非阻塞式的解决方案:原子变量
该节使用阻塞式的解决方案:synchronized
,来解决上述问题,即俗称的**【对象锁】,它采用互斥**的方式让同一 时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住(blocked)。这样就能保证拥有锁 的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
注意:
虽然 java 中互斥和同步都可以采用
synchronized
关键字来完成,但它们还是有区别的:
- 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
- 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点
synchronized
实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。
2-2 synchronized 的使用
语法
synchronized(对象) {
//临界区
}
解决
static int counter = 0;
//创建一个公共对象,作为对象锁的对象
static final Object room = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (room) {
counter++;
}
}
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
synchronized (room) {
counter--;
}
}
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("{}",counter);
}
2-3 方法上的synchronized
-
加在成员方法上,锁是当前实例对象
public class Demo { //在方法上加上synchronized关键字 public synchronized void test() { } //等价于 public void test() { synchronized(this) { } } }
-
加在静态方法上,锁是当前类的Class对象
public class Demo { //在静态方法上加上synchronized关键字 public synchronized static void test() { } //等价于 public void test() { synchronized(Demo.class) { } } }
3.变量的线程安全分析
3-1 成员变量和静态变量是否线程安全?
- 如果它们没有共享,则线程安全
- 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码是临界区,需要考虑线程安全
3-2 局部变量是否线程安全?
-
局部变量是线程安全的
-
但局部变量引用的对象则未必 (要看该对象是否被共享且被执行了读写操作)
- 如果该对象没有逃离方法的作用范围,它是线程安全的
- 如果该对象逃离方法的作用范围,需要考虑线程安全
-
局部变量是线程安全的—每个方法都在对应线程的栈中创建栈帧,不会被其他线程共享
-
如果调用的对象被共享,且执行了读写操作,则线程不安全
-
如果是局部变量,则会在堆中创建对应的对象,不会存在线程安全问题
3-3 常见线程安全类
- String
- Integer
- StringBffer
- Random
- Vector (List的线程安全实现类)
- Hashtable (Hash的线程安全实现类)
- java.util.concurrent 包下的类
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的
例如:
Hashtable table = new Hashtable();
new Thread(()->{
table.put("key", "value1");
}).start();
new Thread(()->{
table.put("key", "value2");
}).start();
- 它们的每个方法是原子的(都被加上了synchronized)
- 但注意它们多个方法的组合不是原子的,所以可能会出现线程安全问题
线程安全类方法的组合
分析下面代码是否线程安全?
Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
table.put("key", value);
}
可以多个方法的组合不是原子的,所以可能会出现线程安全问题
不可变类线程安全性
String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的
有同学或许有疑问,String 有 replace,substring 等方法可以改变值啊,那么这些方法又是如何保证线程安全的呢?
这是因为这些方法的返回值都创建了一个新的对象,而不是直接改变String、Integer对象本身。
3-4 实例分析
例1:
public class MyServlet extends HttpServlet {
// 是否安全?no
Map<String,Object> map = new HashMap<>();
// 是否安全?yes
String S1 = "...";
// 是否安全?yes
final String S2 = "...";
// 是否安全?no
Date D1 = new Date();
// 是否安全?no,引用不变,但是里面的年月日是可变的
final Date D2 = new Date();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
//使用上述变量
}
}
例2:
public class MyServlet extends HttpServlet {
// 是否安全?no,共享的
private UserService userService = new UserServiceImpl();
public void doGet(HttpServletRequest request, HttpServletResponse response) {
userService.update(...);
}
}
public class UserServiceImpl implements UserService {
// 记录调用次数
private int count = 0;//这里多个线程调用可能出问题
public void update() {
// ...
count++;
}
}
例3:
@Aspect
@Component
public class MyAspect {//单例,成员变量会共享
// 是否安全?no
private long start = 0L;
@Before("execution(* *(..))")
public void before() {
start = System.nanoTime();
}
@After("execution(* *(..))")
public void after() {
long end = System.nanoTime();
System.out.println("cost time:" + (end-start));
}
}
4.Monitor
4-1 Java对象头
这里以32位虚拟机为例:
普通对象:
数组对象:
其中Mark Word 结构为:
64 位虚拟机 Mark Word:
4-2 原理之Monitor
Monitor被翻译为监视器或管程
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针
Monitor 结构如下:
- 刚开始 Monitor 中 Owner 为 null
- 当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一 个 Owner
- 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList (进入阻塞状态,BLOCKED )
- Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
- 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲 wait-notify 时会分析
注意:
- synchronized 必须是进入同一个对象的 monitor 才有上述的效果
- 不加 synchronized 的对象不会关联监视器,不遵从以上规则
5.Synchronized原理进阶
5-1 轻量级锁
- 轻量级锁使用场景:当一个对象被多个线程所访问,但访问的时间是错开的(不存在竞争),此时就可以使用轻量级锁来优化。
- 轻量级锁对使用者是透明的,即语法仍然是
synchronized
这里举个栗子:假设有两个方法同步块,利用同一个对象加锁
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}
-
创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录对象,内部可以存储锁定对象的mark word(不再一开始就使用Monitor)
-
让锁记录中的Object reference指向锁对象(Object),并尝试用cas去替换Object中的mark word,将此mark word放入lock record中保存
-
如果cas替换成功,则将Object的对象头替换为锁记录的地址和状态 00(轻量级锁状态),并由该线程给对象加锁
-
如果 cas 失败,有两种情况:
-
如果是其它线程已经持有了该 Object 的轻量级锁,这时表明有竞争,进入锁膨胀过程
-
如果是自己执行了 synchronized 锁重入,那么再添加一条 Lock Record 作为重入的计数
-
-
当退出 synchronized 代码块(解锁时)如果有取值为 null 的锁记录,表示有重入,这时重置锁记录,表示重入计数减一
-
当退出 synchronized 代码块(解锁时)锁记录的值不为 null,这时使用 cas 将 Mark Word 的值恢复给对象头
- 成功,则解锁成功
- 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
5-2 锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
-
当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
-
这时 Thread-1 加轻量级锁失败,进入锁膨胀流程
- 即为 Object 对象申请 Monitor 锁,让 Object 指向重量级锁地址
- 然后自己进入 Monitor 的 EntryList BLOCKED
-
当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程
5-3 自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞
- 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
- 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
- Java 7 之后不能控制是否开启自旋功能
5-4 偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有
偏向状态
- Normal:一般状态,没有加任何锁,前面62位保存的是对象的信息,最后2位为状态(01),倒数第三位表示是否使用偏向锁(未使用:0)
- Biased:偏向状态,使用偏向锁,前面54位保存的当前线程的ID,最后2位为状态(01),倒数第三位表示是否使用偏向锁(使用:1)
- Lightweight:使用轻量级锁,前62位保存的是锁记录的指针,最后两位为状态(00)
- Heavyweight:使用重量级锁,前62位保存的是Monitor的地址指针,后两位为状态(10)
- 如果开启了偏向锁(默认开启),在创建对象时,对象的Mark Word后三位应该是101
- 但是偏向锁默认是有延迟的,不会再程序一启动就生效,而是会在程序运行一段时间(几秒之后),才会对创建的对象设置为偏向状态
- 如果没有开启偏向锁,对象的Mark Word后三位应该是001
撤销偏向
以下几种情况会使对象的偏向锁失效
- 调用对象的hashCode方法
- 多个线程使用该对象
- 调用了wait/notify方法(调用wait方法会导致锁膨胀而使用重量级锁)
批量重偏向
- 如果对象虽然被多个线程访问,但是线程间不存在竞争,这时偏向T1的对象仍有机会重新偏向T2。重偏向会重置Thread ID
- 当撤销超过20次后(超过阈值),JVM会觉得是不是偏向错了,这时会在给对象加锁时,重新偏向至加锁线程
批量撤销
当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的
6.Wait/Notify
6-1 原理
- Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
- BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
- BLOCKED 线程会在 Owner 线程释放锁时唤醒
- WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入 EntryList 重新竞争
这里附《Java并发编程的艺术》里的一张图:
6-2 API介绍
wait()
让进入 object 监视器的线程到 waitSet 等待notify()
在 object 上正在 waitSet 等待的线程中挑一个唤醒notifyAll()
让 object 上正在 waitSet 等待的线程全部唤醒- wait() 方法会释放对象的锁,进入 WaitSet 等待区,从而让其他线程就机会获取对象的锁。无限制等待,直到 notify
- 为止
wait(long n)
有时限的等待, 到 n 毫秒后结束等待,或是被 notify
注意:只有当对象被锁以后,才能调用wait和notify方法
例子:
@Slf4j
public class Test6 {
final static Object lock = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lock) {
log.debug("执行...");
try {
lock.wait();//让线程在lock上一直等待下去
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("其他代码...");
}
}, "t1").start();
new Thread(() -> {
synchronized (lock) {
log.debug("执行...");
try {
lock.wait();//让线程在lock上一直等待下去
} catch (InterruptedException e) {
e.printStackTrace();
}
log.debug("其他代码...");
}
}, "t2").start();
Sleeper.sleep(2);
log.debug("唤醒 lock 上的线程");
synchronized (lock) {
lock.notify(); //唤醒 lock 上的一个线程
//lock.notifyAll(); //唤醒 lock 上所有等待线程
}
}
}
//输出
20:36:08 DEBUG [t1] (Test6.java:13) - 执行...
20:36:08 DEBUG [t2] (Test6.java:25) - 执行...
20:36:10 DEBUG [main] (Test6.java:35) - 唤醒 lock 上的线程
20:36:10 DEBUG [t1] (Test6.java:19) - 其他代码...
6-3 wait/notify 的正确使用
开始之前先看看 sleep(long n)
和 wait(long n)
的区别 :
- sleep 是 Thread 方法,而 wait 是 Object 的方法
- sleep 不需要强制和 synchronized 配合使用,但 wait 需要和 synchronized 一起用
- sleep 在睡眠的同时,不会释放对象锁,但 wait 在等待的时候会释放对象锁
- 他们的状态都是
TIMED_WAITING
以下部分建议参考:《Java并发编程的艺术》读后笔记-part4
等待方遵循如下原则:
- 获取对象的锁
- 如果条件不满足,那么调用对象的wait()方法,被通知后仍要检查条件
- 条件满足则执行对应的逻辑
对应伪代码:
synchronized(对象){
while(条件不满足){
对象.wait();
}
对应的处理逻辑
}
通知方遵循如下原则:
- 获得对象的锁
- 改变条件
- 通知所有等待在对象上的线程
对应伪代码:
synchronized(对象){
改变条件
对象.notifyAll();
}
7.Park/Unpark
7-1 基本使用
park/unpark
都是 LockSupport
类中的方法
//暂停线程运行
LockSupport.park;
//恢复线程运行
LockSupport.unpark(thread);
先 park 再 unpark:
@Slf4j
public class Test24 {
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
log.debug("start...");
Sleeper.sleep(1);
log.debug("park...");
//暂停线程运行
LockSupport.park();
log.debug("resume...");
}, "t1");
t1.start();
Sleeper.sleep(2);
log.debug("unpark...");
//恢复线程运行
LockSupport.unpark(t1);
}
}
13:11:08 DEBUG [t1] (Test24.java:12) - start...
13:11:09 DEBUG [t1] (Test24.java:14) - park...
13:以上是关于并发编程(学习笔记-共享模型之管程)-part3的主要内容,如果未能解决你的问题,请参考以下文章