线程的安全问题
线程的安全问题
原因:操作共享数据
临界区
- 一个程序运行多个线程本身是没有问题的
- 问题出在多个线程访问共享资源
- 多个线程读共享单元也没有问题
- 在多个线程对共享资源读写操作是发生指令交错,就会出现问题
- 一段代码块内如果存在对共享资源的多线程读写操作,
解决方法()
- 阻塞式的解决方案:synchronized,Lock
- 非阻塞式的解决方案原子变量
阻塞式的解决方法
synchronized实际上是使用对象锁保证了临界区内代码的原子性,代码区内的代码是不可分割的,不会被线程切换打断
1 | synchronized(Object){ |
方法一:同步代码块
synchronized(同步监视器){}
1 | class MyThread extends Thread{ |
- 操作共享数据的代码,即为需要被同步的代码
- 共享数据:多个线程共同操作的变量
- 同步监视器,俗称:锁,任何一个对象都可以充当锁。要求是每个人都要共用同一把锁
- 不能包含少了,也不能包含多了
优点:
- 解决了线程的安全性问题
局限性:
- 相当于一个单线程的事,效率低
方法二:同步方法
1 | class MyThread3 extends Thread{ |
总结:
- 同步方法依然涉及到同步监视器,只是不需要显式的声明
- 继承使用的是当前类,实现使用的是当前对象
线程八锁
情况一
1 | public class Test8Lock { |
结果是 a b
或者 b a
情况二
1 | public class Test8Lock { |
结果是 一秒后 a b
或者 b 一秒后 a
情况三
1 | public class Test8Lock { |
结果是 一秒后 a b
或者 b 一秒后 a
,c
在任何时候都可能打印
情况四
1 | public class Test8Lock { |
结果是 b 一秒后 a
情况五
1 | public class Test8Lock { |
结果是 b 一秒后 a
情况六
1 | public class Test8Lock { |
结果是 一秒后 a b
或者 b 一秒后 a
情况七
1 | public class Test8Lock { |
结果是结果是 b 一秒后 a
情况八
1 | public class Test8Lock { |
结果是结果是 b 一秒后 a
变量的线程安全分析
成员变量和静态变量是否线程安全
- 如果没有共享,则线程安全
- 如果他们被共享了,则根据他们的状态是否能够被改变分为两种情况
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码是临界区,需要考虑线程安全
局部变量是线程安全
- 局部变量是线程安全的
- 但局部变量引用的对象未必
- 如果对象没有逃离方法的作用访问,它是线程安全的
- 如果该对象逃离方法的作用范围,需要考虑线程安全
Monitor概念
Java对象头
以32位虚拟机为例
普通对象
数组对象
Mark words的组成部分
synchronized原理
- 对象obj管理操作系统中一个monitor。
- 当Thread1试图进入synchronized代码块时,会从Object的MarkWord找到该对象对应的monitor,并将monitor的owner指向该线程。
- 在Thread1上锁过程中,其他Thread看到monitor有主人,则进入等待队列。
- Thread1执行完后,会进行非公平的竞争锁。
- 图中waitset是获得过锁但是条件不满足进入waiting状态的线程。
synchronized进阶
轻量级锁
轻量级锁的使用场景:如果一个对象虽然有多线程访问,但所线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来进行优化。
轻量级锁对于使用者是透明的,但是语法仍然是synchronized。
假设有两个方法同步方法块,利用同一个对象加锁。
- 创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以储存锁定对象的Mark word: JVM层面
- 让锁记录中的Object指向锁对象,并尝试使用CAS替换Object的Mark Word,将Mark Word的值存入锁记录。
- 如果CAS替换成功,对象头中存储了锁记录地址和状态00,表示由该线程给对象加锁,这时图示如下:
- 如果CAS失败,有两种情况
- 如果是其他线程已经持有了该Object的轻量级锁,这时表示由竞争,进入锁膨胀过程
- 如果是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数
- 当退出synchronized代码块(解锁时)如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数器减一
- 当退出synchronized代码块锁记录不为null时,这时使用CAS将Mark Word的值恢复给对象头
- 成功则解锁成功
- 失败说明轻量级锁已经进行了锁膨胀,或者升级成为了重量级锁,进入重量级锁的解锁流程。
锁膨胀
如果在尝试加轻量锁的过程中,CAS操作无法成功,这时一种情况就是有其他对象为该对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
1 | static Object obj = new Object(); |
- 当新Thread进行轻量级加锁时,Thread 1 已经对该对象进行了轻量级锁
- 这时新Thread加轻量级锁失败进入锁膨胀流程
- 即为Object对象申请Monitor锁,让Object指向重量级锁地址
- 然后自己进入Monitor的EntryList的BLOCKED
自旋优化
重量级锁进行竞争时,还可以使用自选来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞
自旋重试成功的情况
线程1 ( CPU1上 ) | 对象 Mark | 线程2 ( CPU2上 ) |
---|---|---|
- | 01 (无锁) | - |
访问同步块,获取Monitor | 10 (重量锁) | - |
成功 (加锁) | 10 (重量锁) | - |
执行同步块 | 10 (重量锁) | - |
执行同步块 | 10 (重量锁) | 访问同步块,获取Monitor |
执行同步块 | 10 (重量锁) | 自旋重试 |
执行完毕 | 10 (重量锁) | 自旋重试 |
成功 (解锁) | 01 (无锁) | 自旋重试 |
- | 10 (重量锁) | 成功 (加锁) |
- | 10 (重量锁) | 执行同步块 |
… | … | … |
自旋重试失败的情况:
线程1 ( CPU1上 ) | 对象 Mark | 线程2 ( CPU2上 ) |
---|---|---|
- | 01 (无锁) | - |
访问同步块,获取Monitor | 10 (重量锁) | - |
成功 (加锁) | 10 (重量锁) | - |
执行同步块 | 10 (重量锁) | - |
执行同步块 | 10 (重量锁) | 访问同步块,获取Monitor |
执行同步块 | 10 (重量锁) | 自旋重试 |
执行同步块 | 10 (重量锁) | 自旋重试 |
执行同步块 | 10 (重量锁) | 自旋重试 |
执行同步块 | 10 (重量锁) | 自旋重试 |
执行同步块 | 10 (重量锁) | 阻塞 |
… | … | … |
偏向锁
轻量级锁在没有竞争的时候,每次重入仍然需要执行CAS操作。
Java6中引入了偏向锁来做进一步的优化:只有第一次使用CAS将线程ID设置到对象的MarkWord头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有。
偏向状态
一个对象创建时
- 如果开启了偏向锁(默认开启),那么对象创建后,MarkWord最后0x05即最后三位为101,这时它的thread,epoch,age都为0
- 偏向锁是默认延迟加载的,不会再程序启动时立即生效,如果想要避免延迟,可以加VM参数 -xx:BiasedLockingStartupDelay=0来禁用延迟
- 如果没有开启偏向锁,那么对象创建后,MarkWord最后0x05即最后三位为001,这时它的hashcode,age都为0,hashcode在第一次获取时修改。
撤销偏向锁
调用了对象的Hashcode,但是偏向锁的对象的MarkWord中存储的是线程id,如果调用Hashcode会导致偏向锁被撤销。
- 调用hashcode会使得对象不可偏向,从101变成001.
- 轻量锁会在锁记录中记录Hashcode
- 重量级锁会在Monitor中记录Hashcode
- 当有其他线程使用锁对象时,会将偏向锁升级成为轻量级锁
- 调用wait、notify会升级偏向锁
批量冲偏向
批量撤销
当撤销偏向锁阈值超过40次之后,jvm会觉得自己确实偏向错了,根本就不该偏向,于是整个类的所有对象都会变为不可偏向,新建的对象也是不可偏向的。
锁消除
wait notify
- Owner 线程发现条件不满足,调用wait方法,即可进入WaitSet变成WAITING状态
- BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间片
- BLOCKED线程会在Owner线程释放是唤醒
- WAITING线程会在Owner调用notify或notifyAll时唤醒,但唤醒后并不意味着立刻获得所,仍需要进入EntryList重新竞争
API介绍
obj.wait()
让进入Object监视器的线程到waitSet等待,会释放锁
obj.notify()
在Object上正在waitset中等待的线程中挑一个唤醒
obj.notifyAll()
在Object上正在waitset中等待的线程全部唤醒
他们都是在线程之间进行协作的手段,都属于Object对象的方法,必须获取此对象的锁,才能调用这几个方法
上述的方法必须使用在同步代码块或者同步方法之中。
上述的方法的调用者必须是同步代码块或同步方法中的同步监视器。否则会出现异常
上述的方法定义在Object类中
obj.wait(long timeout)
让进入Object监视器的线程到waitSet等待timeout时间
obj.wait(long timeout, int nacos)
让进入Object监视器的线程到waitSet等待timeout + 1时间
wait notify的正确使用姿势
Thread.sleep(long timeout)
和 obj.wait(long timeout)
的区别
Thread.sleep(long timeout)
是Thread的静态方法,obj.wait(long timeout)
是object的方法。Thread.sleep(long timeout)
不需要和synchronized配合使用,但是obj.wait(long timeout)
需要。Thread.sleep(long timeout)
不会释放锁,obj.wait(long timeout)
会释放锁。- 状态都是
TIMED_WAITING
保护性暂停
TODO
锁的应用
应用在单例模式
懒汉式的线程安全问题
- 效率较差
1 | class Bank{ |
- 效率高一些
1 | class Bank1{ |
死锁
- 不同的线程分别占用对方需要的同步资源不放弃,都在等待对方先放弃,这就形成了线程的死锁
- 出现死锁后,不会出现异常,不会出现提示,只是所有的线程都处于阻塞当中,无法继续
解决方法:
- 专门的算法、原则
- 尽量 减少同步资源的定义
- 避免嵌套同步
1 | public class DeadLock { |
方法三:lock锁
1 | class Windows implements Runnable{ |
synchronized和lock锁的异同:
相同点:都能解决线程安全问题
不同点:lock锁手动的锁定和手动的解锁,synchronized自动释放
JDK5.0新增的线程创建接口
新增方式二线程池
背景:经常创建和销毁、使用量特别大的资源,比如并发情况下的线程,对性能影响很大
思路:提前创建好多个线程,放入线程池中,使用时使用时直接获取,使用结束后放回。可以避免重复创建和销毁,实现重复利用,类似于生活中的公共交通。
主要特点是:线程复用;控制最大并发数;管理线程
优点:
- 提高响应的速度
- 降低了资源的消耗
- 便于线程的管理
线程池相关的API的使用
1 | class NumThread implements Runnable{ |