基础概念
线程安全
当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的
线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步, 否则的话就可能影响线程安全。
JAVA API 中存在线程安全问题的类:
- StringBuffer
- Vector
- ...
原子性
if (condition){ a++; // 当此段代码运行在多线程的环境时,则会产生线程安全问题}
竞态条件(race condition): 多个进程(线程、协程)竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件
当一个线程观察到一个变量的值时,它并不是正确有效的,可能已在此之前就被其他线程修改过,基于失效的观察结果,就是大多数竞态条件的本质
一种常见的竞态条件发生在单例构造模式中:
public static Object get(){ if (instance == null){ instance = new Object(); } return instance;}
为了解决观察失效这个问题,也就是为了避免竞态条件,就需要对一组操作进行原子化,即不可分割。要不全做,要不就不做。这组操作称之为复合操作。
- 复合操作:由一系列原子操作构成
不可变
不可变(无状态)的对象一定是线程安全的
这种对象的接口一般需要精心设计 最简单的方式是所有成员变量设置为final
绝对线程安全
不管运行时环境如何,调用者都不需要任何额外的同步措施
相对线程安全
要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性
线程兼容
对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用
线程对立
指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码
线程安全的实现
互斥同步(加锁)
- synchronized
- Lock
非阻塞同步
- CAS
无同步方案
- 可重入代码:类似于函数式编程,不会产生副作用
- 线程本地存储
synchronized
使用了锁对象,这个锁对象一瞬间只能被一个线程所持有
synchronized(this){ // 可以是任意一个对象 // 需要同步操作的代码}
public synchronized void method(){ // 也可以同步静态方法,等同于上面的synchronize(this) // 可能会产生线程安全问题的代码 }
synchronized是可重入锁
重入:某个线程试图获得一个已经由它持有的锁
程序执行过程中发生异常,锁会被释放
不能使用String常量,以及int long等原始类型
synchronized底层
JDK早期的 使用的重量级实现 也就是在 OS 层面,后来的进行了改进
synchronized实现过程
- java代码:synchronized
- 字节码: monitorenter monitorexit
- 执行过程中会进行锁升级
- lock comxchg
锁升级
- 在 markword 中记录记录获取锁的这个线程ID 此时是偏向锁。线程通过判断这个ID是否为自身来判断自己是不是获得了偏向锁。偏向锁在资源无竞争情况下消除了同步语句,连CAS操作都不做了,提高了程序的运行性能,是JVM 检测到不同的竞争状况时,会自动切换到适合的锁实现
但如果应用程序里所有的锁通常处于竞争状态,那么偏向锁就会是一种累赘
# 关闭偏向锁-XX:UseBiasedLocking=false
- 如果发现 markword 的不是自己的线程 ID 则升级为自旋锁
- 在自旋一定次数以后,不能获得锁,则升级为OS层面的重量级锁
- 执行时间短(加锁代码),线程数少,用自旋
- 执行时间长,线程数多,用系统锁
锁降级
锁降级发生的条件会比较苛刻,锁降级发生在Stop The World期间,当JVM进入安全点的时候,会检查是否有闲置的锁,然后进行降级
锁
非阻塞同步
互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步
悲观的并发策略:认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁
乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施
悲观锁 乐观锁
- 乐观锁
总是认为不会产生并发问题,每次去取数据的时候总认为不会有其他线程对数据进行修改,因此不会上锁,但是在更新时会判断其他线程在这之前有没有对数据进行修改,一般会使用版本号机制或CAS操作实现
update table set x=x+1, version=version+1 where id=${id} and version=${version};
- 悲观锁
总是假设最坏的情况,每次取数据时都认为其他线程会修改,所以都会加锁(读锁、写锁、行锁等),当其他线程想要访问数据时,都需要阻塞挂起
synchronized是悲观锁
自旋锁
线程反复检查锁变量是否可用。由于线程在这一过程中保持执行,因此是一种忙等待
在JDK9之后新增了 Thread.onSpinWait(),这是针对短期等待的性能优化技术,没有任何行为上的保证,而是对 JVM 的一个暗示,JVM 可能会利用 CPU 的 pause 指令进一步提高性能
class EventHandler { volatile boolean eventNotificationNotReceived; void waitForEventAndHandleIt() { while ( eventNotificationNotReceived ) { java.lang.Thread.onSpinWait(); } readAndProcessEvent(); } void readAndProcessEvent() { // Read event from some source and process it . . . }}
分布式锁
- zookeeper与redis实现
volatile
任何对被volatile关键字修饰的变量都会在主内存操作 不会操作副本
volatile变量操作时需要同步给内存变量 所以一定会使线程的执行速度变慢
而锁机制通过读入副本 释放锁写入主内存来包装可见性
- 保证线程可见性
- [MESI](/计算机系统/程序结构和执行/存储器层次结构.html#MESI) 缓存一致性协议
- 虽然 CPU 有一些机制来保证缓存一致性,但这种保证需要数据被写入主存才算对写入的完成,这能确保其他线程看到修改后的数据,但同时也增加了性能的负担,所以只有声明了 volatile 的变量才会保证可见性
- 禁止指令重排序
- DCL(double check lock) 单例
重排序的3种类型:
- 编译器重排
- 指令并行重排
- 内存系统重排
可见性
在没有同步的情况下,编译器或者处理器都会对一些上下文无关的指令进行重排序,这可能会导致一个线程修改了某一个数值,而另一个线程无法马上读取到修改后的数值
失效数据
非原子的64位操作
在java当中,一个64位大小的数值可以被分为2个32位的操作
在Java内存结构中,既然堆是共享的,为什么在堆中会有内存不可见问题。现在计算机CPU为了高效,往往会在高速缓存区中缓存共享变量
为什么要重排序?还是为了性能,流水线技术的原理是指令1还没有执行完,就可以开始执行指令2,而不用等到指令1执行结束之后再执行指令2
加锁与可见性
之所以要在访问某个共享的可变变量时要求所有线程在锁上同步,就是为了确保读写可见性。 加锁的含义不局限与互斥行为,还包括内存可见性
volatile是比synchronized更为轻量级的同步机制,它无法进行互斥操作,但能保证内存可见性
- 典型用法
volatile boolean f;while (f){ // do something}
CAS
- AtomicInteger 等原子类的实现
stateDiagram-v2 开始 --> 读取当前值E 读取当前值E --> 计算结果值V 计算结果值V --> 比较E和当前新值N 比较E和当前新值N --> 更新为新值V: 相等(存在ABA问题:其他线程修改数次后值和原值相同) 比较E和当前新值N --> 读取当前值E: 不相等(其他线程修改为不同值) 更新为新值V --> 结束
它包含三个参数CAS(V,E,N): V表示要更新的变量,E表示预期值,N表示新值。仅当V值等于E值时,才会将V的值设为N,如果V值和E值不同,则说明已经有其他线程做了更新,则当前线程进行自旋重复上述操作或者什么都不做。最后,CAS返回当前V的真实值
CAS是CPU原语支持 Java 通过native方法调用汇编指令来实现
// 模拟CAS实现int synchorized cas(int e, int n) { int oldValue = value; if (oldValue == e) value = n return oldCount}
ABA问题
如果在这段期间曾经被改成B,然后又改回A,那CAS操作就会误认为它从来没有被修改过
解决方法:版本号
在大多数情况下 ABA问题并不会影响到程序的正确性
使用AtomicStampedReference实现
自旋开销
CAS多与自旋结合。如果自旋CAS长时间不成功,会占用大量的CPU资源。
让JVM支持处理器的pause指令可以解决这个问题。
在Java层,也可以通过手动yield线程或者sleep来解决
非阻塞算法
在某个算法内,一个线程的失败或挂起不会导致其他线程的失败或挂起
unsafe类
直接操作JVM里的内存
JDK9之后无法使用了
- allocateMemory 直接分配内存
- freeMemory 释放内存
- compareAndSet CAS操作
Java 与协程
- Java 内核线程的局限性
内核线程1:1映射到Java上,当面对大量请求时,线程切换的成本开销远远大于计算本身的开销
协程的主要优势是轻量,一个协程的实现特例被称之为纤程
新并发模型下,一段使用纤程并发的代码会被分为两部分——执行过程(Continuation)和调度器(Scheduler)。执行过程主要用于维护执行现场,保护、恢复上下文状态,而调度器则负责编排所有要执行的代码的顺序