3. 管程(Monitor)
3.1线程安全问题:
单线程下:自增自减不会有交错执行问题
多线程下,8行代码出现负数情况
出现正数情况:
问题出在多线程访问共享资源,在多个线程对共享资源读写操作时发生指令交错,就会出现问题,一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
竞态条件 Race Condition :多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件
3.2 synchronized 解决方案
synchronized,来解决上述问题,即俗称的【对象锁】,它采用互斥的方式让同一 时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁 的线程可以安全的执行临界区内的代码,不用担心线程上下文切换
synchronized(对象) // 线程1, 线程2(blocked)
{
临界区
}
解决办法:
synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切 换所打断。
面向对象改进
把互斥的逻辑都放入一个类中,对共享资源的保护由对象来实现
class Room {
int value = 0;
public void increment() {
synchronized (this) {
value++;
}
}
public void decrement() {
synchronized (this) {
value--;
}
}
public int get() {
synchronized (this) {
return value;
}
}
}
@Slf4j
public class Test1 {
public static void main(String[] args) throws InterruptedException {
Room room = new Room();
Thread t1 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.increment();
}
},
"t1");
Thread t2 = new Thread(() -> {
for (int j = 0; j < 5000; j++) {
room.decrement();
}
},
"t2");
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("count: {}" , room.get());
}
}
3.3 方法加synchronized
synchronized加在成员方法上,相当于锁住this对象
class Test{
public synchronized void test() {
}
}
等价于
class Test{
public void test() {
synchronized(this) {
}
}
}
加在静态方法上,相当于锁住类对象
class Test{
public synchronized static void test() {
}
}
等价于
class Test{
public static void test() {
synchronized(Test.class) {
}
}
3.4 变量的线程安全分析
-
成员变量和静态变量没有共享或被共享时只有读没有写操作是安全的,涉及到写操作时,则这段代码是临界区,需要考虑安全问题
-
局部变量线程安全,但要注意引用的对象有没有逃离方法的作用范围,逃离则需要考虑线程安全问题
- 对于public方法,推荐加final,防止被覆盖
- 对于需要访问公共区的方法建议private,防止子类覆盖,如果子类覆盖且重新开了线程,则会引起安全问题
-
常见线程安全类,多个线程调用同一个实例的某个方法时,线程安全,每个方法是原子的,组合起来则不是原子的
- String
- Integer
- StringBuffer
- Random
- Vector
- Hashtable
- java.util.concurrent简称juc
3.5 synchronized底层
3.5.1 Java对象头
3.5.2 Monitor锁
- 被翻译为监视器或者管程,由操作系统提供
- 把synchronized对象的MarkWord指向操作系统提供的一个Monitor对象,使他俩关联起来
- 第一个执行到加了锁对象的线程,会拿到Monitor中的Owner,其他线程再访问时,将会被放到EntryList,且由运行态变为阻塞态
- Owner线程执行完以后,将MarkWord重置然后会唤醒EntryList中的线程来竞争,注意竞争不一定按照先来后到的规则
3.5.3 synchronized优化
- 因为一直使用Monitor锁开销比较大,从使用Monitor锁→轻量级锁,偏向锁
轻量级锁
-
使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(没有竞争),可以用轻量级锁优化
-
对用户透明,即不改名声明方式,依旧是
synchronized
-
过程:
-
创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,存储锁定对象的MarkWord
-
让锁记录中Object reference指向锁对象,尝试用cas替换Object的MarkWord,将MarkWord值存入锁记录
-
如果替换成功,对象的MarkWord存储了锁记录地址和状态00(表示轻量级锁)
-
如果替换失败,表明两种情况:
- 其他线程已经持有了该Object的轻量级锁,说明有竞争,要锁膨胀
- 自己执行了synchronized锁重入,再添加一条LockRecord(取值为null)作为重入的计数
-
解锁时如果有取值为null的锁记录,表示有重入,重置该锁记录,表示重入计数减一
-
解锁时如果锁记录值不为null,使用cas将MarkWord值恢复给对象
- 成功的话就代表解锁成功
- 失败的话说明进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程
-
锁膨胀
-
加轻量级锁失败,锁膨胀:将轻量级锁变为重量级锁
-
过程:
- 为对象申请一个Monitor锁,让Object指向重量级锁地址10
- 新申请轻量级锁的线程加入Monitor的EntryList
-
原线程退出同步代码块解锁时,使用cas恢复对象头失败。进入重量级锁解锁过程,也就是Monitor的解锁过程,将Monitor对象的Owner设置为null,唤醒EntryList中被阻塞的线程
自旋优化
- 自动重试获取锁,如果在这个过程中获得到了锁,就可以避免阻塞
- 需要占用CPU,在另一个CPU上运行
偏向锁
- 优化在没有竞争的重入时依旧需要cas操作的开销
- 只有第一次使用CAS将线程id设置到对象的MarkWord头,之后发现这个线程id是自己的就表示没有竞争,不用重新CAS。只要不发生竞争,对象就归线程所有
- 默认开启,MarkWord值后三位为101,thread,epoch,age都为0
- 默认延迟,不会立刻生效,需要虚拟机配置
XX:BiasedLockingStartupDelay=0
来禁用 - 如果没开偏向锁,MarkWord后三位为001,hashcode、age都为0
- 撤销偏向锁:
- 调用对象的hashcode时,会撤销偏向锁
- 当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁(这两个锁都是没有竞争的时候)
- 调用wait/notify也会撤销偏向锁
- 批量重偏向:如果对象被多个线程访问,但没有竞争,这时偏向了线程T1的对象仍有机会重新偏向T2,重偏向会重置对象的threadID。撤销偏向锁超过20次后,jvm会给对象加锁时重新偏向至加锁线程
- 批量撤销:如果撤销偏向锁操作超过40次时,jvm会把整个类的所有对象都会变为不可偏向的,新建对象也不可偏向
锁消除
- JIT会对java字节码进一步优化,如果对象不可能被共享,加锁就无意义,会被消除
- 默认开启,
-XX:EliminateLocks
3.6 wait notify
-
Monitor锁的Owner线程发现条件不满足,调用wait方法,进入waitset(存放条件不满足进入等待状态的线程)变为waiting状态
-
waiting与blocked不同:waiting是获得了锁又放弃锁,blocked是等待锁
-
waiting线程会在owner线程调用notify或notifyAll时唤醒,唤醒后进入EntryList重新竞争
-
API
- obj.wait()进入Object的Monitor的线程到waitset等待
- obj.notify()挑一个在waitset中的线程唤醒
- obj.notifyAll()让object上正在waitSet等待的线程全部唤醒
- 是obj的方法,必须获得了锁才能使用这些方法
-
sleep是Thread方法,wait是Object方法,sleep不需要强制和synchronized配合,wait需要和synchronized一起,sleep期间不会释放锁,wait期间可以释放锁。状态都是TIMED_WAITING
-
正确姿势:
-
使用sleep会占用锁影响其他线程的执行,而且必须sleep到指定的时间才能唤醒。
-
使用wait notify,在wait期间不会占用锁,会提高并发效率,但是会存在notify随机叫醒(虚假唤醒),影响逻辑
-
使用notifyAll,解决一部分线程虚假唤醒问题,但是会将还不满足执行条件的线程唤醒,可以将判断语句改为while,让线程继续waiting
-
模版
synchronized(lock){ while(条件不成立){ lock.wait(); } //干活 } //另一个线程 synchronized(lock){ lock.notifyAll(); }
-
3.7 Park&Unpark
LockSupport类中
//暂停当前线程
LockSupport.park()
//恢复某个线程的运行
LockSupport.unpark(暂停线程对象)
- unpark既可以在park之前调用,也可以在park之后调用
- 不需要配合Monitor锁
- 以线程为单位
原理
每个线程都有自己的一个Parker对象,有三部分:_counter, _cond, _mutex
- 调用park时会首先检查counter,如果counter等于0,将会拿到mutex互斥锁,并且进入cond阻塞,如果counter为1,将会使counter为0,但不会休息
- 调用unpark会使counter为一,如果线程在阻塞将会唤醒该线程并使counter为0
3.8 线程状态转换
- 调用start会从new变为runnable
- wait notify会使waitng和runnable之间转换,但是被notify唤醒的线程可能会竞争锁失败变为blocked
- 当前线程调用t.join()方法(等待t线程结束)时,线程会从runnable变为waiting,t线程运行结束,或调用了interrupt()时,线程会从waiting变为runnable
- park、unpark
- obj.wait(long n), 会从runnable变为timed_waiting,等待时间超过n毫秒或者notify、interrupt会唤醒去竞争锁,竞争失败会变为blocked
- t.join(long n)
- Thread.sleep(long n)
- parkNanos(long nanos)或者parkUntil(long millis),unpark
- 获取对象锁竞争失败就会进入blocked,同步代码块进行完毕唤醒所有blocked线程竞争,失败继续blocked
- 所有代码运行完毕,进入terminated
3.9 多把锁
设置多把锁提高并发度,但会产生死锁问题
3.10 活跃性
死锁
互相拿到锁,但又要请求对方的锁,就会产生死锁问题
定位死锁
两个工具定位死锁:jconsole,jstack
哲学家就餐
五个哲学家五根筷子,拿两个筷子才能吃饭,但是如果五个人分别拿了一根筷子就会产生死锁
活锁
互相影响结束条件,导致无法结束,只要把执行时间交错开,比如改变睡眠时间
饥饿
一个线程优先级太低,始终得不到CPU调度执行,也不能结束
按照相同顺序加锁解决之前的死锁问题时,就会产生饥饿问题
3.11 ReentrantLock可重入锁
特点:
-
可中断
-
可设置超时时间
-
可以设置为公平锁(先进先出)
-
支持多个条件变量,多个waitset
-
基本语法
//获取锁 reentrantLock.lock(); try{ //临界区 }finally{ //释放锁 reentrantLock.unlock(); }
可重入
指同一个线程如果是锁的持有者有权力再次获取锁,不可重入锁指即使已经获得了锁,第二次获得锁时会被锁挡住
可打断
lock.lockInterruptibly();//可被打断的
使用这个锁,可以使用Interrupt打断正在阻塞的线程,防止无限期的等待,也是一种避免死锁的方法
锁超时
lock.tryLock();//尝试获得锁
lock.tryLock(超时时间,时间单位);//等待设定时间后,再尝试获得锁
也可被打断
解决哲学家问题
使用trylock方法,获取筷子失败时,放下已经有的筷子,即可解决问题
公平锁
-
ReentrantLock默认是不公平的
-
本意是解决饥饿问题
-
公平锁一般没有必要,会降低并发度
条件变量
-
ReentrantLock支持多个条件变量,也就是多个waitset
-
与wait notify类似,只不过有多个condition,这里condition相当于“休息室”
//创建conditon,可以创建多个
Condition conditino1 =lock.newCondition();
Condition condition2 =lock.newCondition();
lock.lock();
condition1.await();
condition1.signal();
condition1.signalAll();
- await前需要获得锁
- await执行后,会释放锁,进入conditionObject等待
- await的线程被唤醒后去重新竞争lock锁
- 竞争lock锁成功后,从await后继续执行
3.12 总结
- 临界区代码就是访问共享资源时又有读又有写的代码片段
- 有两种关键字对资源进行保护:synchronized、lock(ReentrantLock)
- 明白互斥和同步的区别:
- 互斥:临界区代码不被上下文切换而交错,保证代码的原子性
- 同步:某个线程条件不满足时等待,条件满足后继续运行
- 管程即Monitor