线程安全的分类
JAVA中操作共享数据按照线程安全程度大致分为5类:
不可变,绝对线程安全,相对线程安全,线程兼容和线程对立
- 不可变
只要一个不可变的对象被正确的构建出来,没有发生this引用逃逸,那其外部的可见状态永远不会改变,例如final修饰的对象,JAVA API中常见的有String,Long,Double等 - 绝对线程安全
绝对线程安全要达到不管运行时环境如何,调用者都不需要任何额外的同步措施,通常付出的代价很大,在API中标注自己是线程安全的类,大多不是绝对线程安全的,例如Vector类
@Slf4j
public class TestVector {
private static Vector<Integer> vector = new Vector<>();
public void test() {
int count = 0;
while (true) {
count ++;
for (int i = 0; i < 10; i++) {
vector.add(i);
}
Thread removeThread = new Thread(() -> {
for (int i = 0; i < vector.size(); i++) {
vector.remove(i);
}
});
Thread printThread = new Thread(() -> {
for (int i = 0; i < vector.size(); i++) {
log.info("v: {}", vector.get(i));
}
});
removeThread.start();
printThread.start();
if (count > 20) break ;
}
}
}
- 相对线程安全
相对线程安全就是我们通常意义上的线程安全,它需要保证对这个对象单独的操作是线程安全的,但对一些特定的连续操作可能需要在调用段使用额外的手段保证调用的正确性,JAVA API中例如Vector,HashTable,ConcurrentHashMap - 线程兼容
线程兼容是指对象本身不是线程安全的,但是可以通过调用端正确的使用同步手段来保证对象在并发环境中可以安全的使用,平常我们说一个类不是线程安全的,绝大多数是指这种情况,JAVA API中大部分的类都是属于线程兼容的,例如ArrayList,HashMap - 线程对立
线程对立指无论调用段是否采取了同步措施,都无法在多线程的环境中使用并发的代码,这种情况通常都是有害的,应该避免,例如Thread类的suspend()和resume()方法,这两个方法已经被声明废弃了
线程安全的实现方法
- 互斥同步
互斥同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只能被一个线程使用,而其他线程需要阻塞等待。
最基本的手段就是synchronized关键字,属于重量级锁有两点基本特性
- synchronized同块对同一条线程来说是可重入的,不会出现自己把自己锁死的情况
- 同步块在已进入的线程执行完毕前,会阻塞后面其他线程的进入 还有一个方法就是ReentrantLock,具备高级特性:等待可中断,可实现公平锁,以及锁可绑定多个条件 等待可中断:
指当持有锁的线程长期不释放锁的时候,等待的线程可以选择放弃等待,改为处理其他事情 公平锁:
指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获取锁,而synchronized是非公平锁 锁绑定多个条件:
指ReentrantLock可以同时绑定多个Condition对象,而synchronized中,多个对象则需要额外添加锁
- 非阻塞同步
简单说就是先执行操作,如果没有其他线程争用共享数据,那操作就成功了,如果有,就采取其他补偿措施,例如不断重试,这种实现不需要把线程挂起,即非阻塞同步
非阻塞同步需要硬件支持,因为操作和冲突检测这两个步骤需要具备原子性,而这需要靠硬件来保证
CAS指令需要3个操作数,分别是内存位置V, 旧的预期值A,新值B。指令执行时,当且仅当V符合旧预期A的时候,处理器用新值B更新V的值,否则不更新,且无论是否更新了V,都会返回V的旧值,这即时一个原子操作
CAS操作有一个漏洞即ABA问题,是指线程X在获取了旧值B的时候,另外有线程更改了V的值,但是在X更新操作之前,又有线程将V的值改回了B,使CAS操作的时候认为值未被修改过。J.U.C包为了解决这个问题,提供了AtomicStampedReference类,通过控制版本来保证CAS的正确性,不过大部分情况下ABA问题并不影响程序并发的正确性,这个类比较少用,而且如果要严格保证顺序,则传统的互斥同步可能更为高效
JAVA API中相关类有:AtomicLong,AtomicBoolean,AtomicInteger - 无同步方案
有些代码天生就是线程安全的,例如下面两种
- 可重入代码 代码执行的任何时候中断再回来,不影响最终的输出
- 线程本地存储 如果一个变量要被某个线程独享,显然使用参数传递又比较的麻烦的时候,可以采用ThreadLocal实现线程本地存储的功能
JAVA锁机制
- 悲观锁/乐观锁
悲观锁:在多线程并发环境中时, 它对数据出现并发冲突,持保守态度(悲观)。它假定一定出现冲突,所以在数据处理过程中,将数据锁定,使是数据处于独占状态。
实现类似于互斥同步,例如synchronized,ReentrantLock
乐观锁:在多线程并发环境中时,它对数据出现并发冲突,持积极态度(乐观)。在数据处理中,假定数据不存在冲突,从而不锁定数据。为了保证数据的一致性,数据通常会有一个版本号。乐观锁通过版本号判断,数据是否被其他人更新过。如果不一致,就重试,或者放弃修改数据。
实现类似于非阻塞同步,例如CAS机制,实现类比如AtomicLong,AtomicBoolean,AtomicInteger - 公平锁/非公平锁
公平锁:指多个线程按照申请锁的顺序来获取锁
非公平锁:指多个线程获取锁的顺序并不按照申请锁的顺序,有可能后申请的线程比先申请的优先获取锁
对比:
- 非公平锁的吞吐量比公平锁大
- 非公平锁可能造成优先级反转,或者某些线程一直拿不到锁(饥饿现象)
- ReentrantLock可以通过构造函数指定锁是否公平,默认非公平锁
- synchronized是非公平锁
- 可重入锁
指自己可以再次获取自己的内部锁,比如线程内部获取了一个锁,之后可以重复获取该锁,避免自己锁死自己,ReentrantLock和synchronized都是可重入锁 - 独享锁/共享锁
独享锁:指锁一次只能被一个线程锁持有,例如ReentrantLock,synchronized
共享锁:指锁可被多个线程锁持有,例如ReadWriteLock,读锁时共享锁,写锁时独享锁,读写,写读,写写的过程均是互斥 - 分段锁
分段锁时指锁的一种设计,例如jdk1.7中的ConcurrentHashMap,目的是细化锁的粒度,可以不用锁整个范围 - 自旋锁与自适应自旋锁
自旋锁:指当发生线程竞争时,不阻塞另一个线程,而是使线程执行一个忙循环(自旋),自旋等待避免了线程切换的开销,但是占用处理器的时间,因此自旋等待时间必须有一定的限度,可以用过-XX:PreBlockSpin更改,默认是10次
自适应自旋:JDK1.6中引入了自适应自旋锁,自适应意味着自旋的时间不再固定而是由前一次同一个锁上的自旋时间和锁的拥有者状态来决定 - 锁消除
锁消除指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除,主要判定依据来自于逃逸分析的数据支持,例如下面的代码:
StringBuffer的append()方法中都有一个同步块,但是虚拟机观察stringBuffer对象发现他的动态作用域都在concatString()方法内部,也就是说stringBuffer的所有引用都不会逃逸出concatString()方法,因此这里的锁可以被安全的消除掉
public String concatString(String str1, String str2, String str3){
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(str1);
stringBuffer.append(str2);
stringBuffer.append(str3);
return stringBuffer.toString();
}
- 锁粗化
如果一系列连续操作都是对同一个对象反复加锁和解锁,甚至加解锁操作是出现在了循环体中,那么即时没有线程竞争,也会导致不必要的性能损耗,上面stringBuffer中连续的append方法就是这类情况,如果虚拟机检测到有这样一串操作的话,会把加锁同步的范围扩展(粗化)到整个操作序列外部,上面stringBuffer的操作就是扩展到第一个append操作之前和最后一个append操作之后,这样只需要加锁一次就可以了 - 重量级锁/轻量级锁/偏向锁
重量级锁:传统的锁机制就是重量级锁,例如synchronized
轻量级锁:本意是在没有多线程竞争的情况下,减少传统的重量级锁使用操作系统互斥量产生的性能损耗
偏向锁:目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能,如果说轻量级锁时在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了,偏向锁可以通过-XX:-UseBiasedLocking来禁止
这里首先有一个概念是Mark Work
HotSpot虚拟机的对象头分为两部分信息,第一部分用于存储对象自身的运行数据,如哈希码(Hash Code),GC分代年龄(Generational GC Age)等,这部分数据的长度在32位和64位虚拟机中分别为32bit和64bit,这就是Mark Word
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码,对象分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
空,不需要记录信息 | 11 | GC标记 |
偏向线程ID,偏向时间戳,对象分代年龄 | 01 | 可偏向 |
参考:
[1] 深入理解Java虚拟机第二版
欢迎关注微信交流