文章目录
线程安全
一、定义
- 多线程环境下使用某个类,无论如何调度,该类始终保持正确的行为,则是线程安全的类。
二、条件
2.1 线程安全条件
- 操作原子性:操作不能本中断
- 内存可见性:一个线程修改后对另一个线程立刻可见(部分不是严格的场景仅要求可见性不要求原子性)
2.2 线程不安全
- 在多个线程之间有共享状态,且不满足前面的2个条件,则可能出现线程不安全。
- 状态:类的变量和对应值的集合
三、如何做到线程安全?
- 避免线程之间的共享状态或者保证共享状态安全,则可以保证线程安全
3.1 栈封闭
- 封装在一个方法栈帧里面的变量因为无法被其他线程访问,因此是安全的,这样避免了线程间的共享状态。(即使定义的变量存放在堆内存,也是安全的,其他线程无法访问该变量)
3.2 无状态类
- 没有成员变量的类。该类的实例被多线程执行也没有问题,因为没有共享状态。(实际上此时类相当于只有方法或者常量,方法是线程安全的,不同线程执行的时候是不同线程的方法栈帧,对于Spring里面的bean很像这种情况)
3.3 类不可变
- 给成员变量加final关键字。不可变的类所有的成员变量应该是私有的,且属性不需要被修改的话成员变量使用final修饰。final修饰引用类型需要确认该引用类型内部属性不可以被修改才是线程安全的,否则不是。 如String和基本包装类型。
- 不提供任何修改类的方法,同时成员变量也不作为任何方法的返回值。
3.4 volatile
- 保证类的可见性。不是线程安全的,对于不是严格的线程安全场景可以考虑,适用于单线程写多线程读的场景。比如ConcurrentHashMap的get方法中的应用。
3.5 加锁和CAS
- 加锁可以保证原子性和可见性,因此可以保证线程安全。
- CAS是线程安全的,利用到硬件指令层面的支持。
3.6 安全的发布
- 对于类的成员变量,如果通过get方法或其他方式将变量的引用返回到外部,外部就会修改这个变量,造成线程安全问题,这就是不安全的发布。一个安全的发布要保证类的外部无法通过获取到属性的引用来修改该属性。
3.7 ThreadLocal
- ThreadLocal线程本地变量可以保证线程安全。
四、线程不安全引起的问题
4.1 死锁
- 由于竞争资源或者彼此通信
4.1.1 死锁的条件
条件 | 描述 |
---|---|
资源竞争 | 竞争资源一定大于1个,且小于竞争的线程数量 |
不可剥夺 | 获得资源后不可剥夺,只能由线程自己释放 |
资源独占 | 资源被线程获取后,不能被其他线程获取 |
循环等待 | 线程获取部分资源且等待其他资源的过程中,不会释放已持有资源 |
- 查看死锁
jps -m : 查看线程id
jstack 线程id : 查看锁的持有情况
4.1.2 解决死锁
- 保证获取锁的顺序。拿锁的时候,按照约定的顺序。(发生死锁的原因就是线程获取锁的顺序不一致,避免多个线程各获取到部分锁,然后都进入阻塞)
4.1.3 动态顺序死锁
- 动态顺序死锁:方法内部实现的时候认为加锁是按照顺序的,但因为传参顺序不一致导致死锁。比如转账,因为2个账号都是参数,而方法内部按照形参来确定加锁顺序,但是可以同时发生相互的转账,此时锁的顺序恰好相反,造成死锁,示例如下:
比如时刻T1发生A转账给B ,传参是(A,B),方法先在T2时刻给A加锁,再在T3时刻给B加锁,
比如时刻t1发生B转账给A ,传参是(B,A),方法先在t2时刻给B加锁,再在t3时刻给A加锁,
如果整个时序是下面这样的,那就会死锁
T1 ---> t1 ---> T2 ---> t2 --->(死锁)---> T3 ---> t3
4.1.4 解决动态顺序死锁
-
上例说明 : 即使保证传参顺序一致也不能保证加锁的顺序是一致的。可以使用hashCode方法,比如总是先锁hashCode大的,但是不能保证用户重写了hashCode方法,这里可以利用jdk的identifyHashCode来获取原生hashCode值,这样不管传参顺序如何加锁顺序是不会变化的。HashCode一样的话(概率极低),那么可以竞争内部预先定义好的一把锁(这种情况极少执行)。
-
另外也可以通过尝试加锁的方式解决加锁顺序的问题。尝试拿锁成功才操作,失败的话,就休眠随机值之后再去尝试拿锁(避免活锁)。因为尝试拿锁不会一直持有锁不放,因此不会死锁。(注意这里代码细节,两把锁的获取过程都需要使用try…finally,这样内层tryLock失败的时候,外层的锁也会在外层的finally中释放,避免死锁)
4.2 活锁
- 在4.1.4的尝试拿锁的方案中休眠随机数就是为了避免活锁,避免2个线程相互之间都不断的尝试拿锁失败,休眠可以很好的避开竞争,提高效率(活锁错开)。
4.3 线程饥饿
- 低优先级线程得不到调度
五、性能和思考
- 并发是为了提高性能,但是使用不当可能性能还不如单线程。引入多线程会引起更多的线程开销。
5.1 衡量性能的指标
- 服务时间,响应时间(多快),吞吐量(处理多少),响应时间和吞吐量很多时候是对立甚至是相互矛盾的。对于服务器,相比响应时间,可能更加偏向于吞吐量
- 黄金原则:首先保证功能正确,如果速度达不到,再提高性能,避免过早优化。
- Amdahl定律(F表示必须串行化执行的部分比重):
Speedup <= 1/(F+ (1-F)/n )
5.2 影响性能因素
5.2.1 上下文切换
- Cpu时间片轮转调度(可能需要耗费几微妙,需要5k-1w个时钟周期)
- 合理配置线程池参数,避免不必要的线程切换
5.2.2 内存同步
- 加锁(显示锁和隐式锁)
- 进行锁相关的优化,参照第六点
5.2.3 阻塞
- 造成线程的挂起和唤醒,带来线程的上下文切换
- IO等操作,需要视具体情况优化
六、锁优化
- 减小锁的范围:避免对不需要同步的代码加锁,避免同步代码块内的等待,休眠
- 减小锁的粒度:增加不同的锁来保护更细化的资源(比如并发多线程访问数据库加锁,可以通过细粒度的加锁而不是全部加锁,细粒度可能是类似于用户区域之类的),比如多个共享资源是独立变化的,可以考虑用不同的锁来一一保护这些对象,需要注意避免死锁
- 锁粗化:避免多个小段代码块频繁加锁,和第一点要折中处理
- 锁分段:ConcurrentHashMap,也是一种减小锁粒度的思想
- 替换独占锁:使用读写锁、CAS或者并发容器来避免使用独占锁
七、单例模式
- 单例模式需要保证线程安全,可以参照设计模式部分"单例模式"
八、小结
- 文章对线程安全的知识进行了小结,有助于我们识别线程安全问题,也包括部分优化手段