Java Concurrency (1)
最近在学习和使用Kotlin的Coroutine,也会涉及到线程和并发,那么刚好就把整个Java的并发系统性的总结一下.
最开始准备写一篇,能够完整的把并发相关的都囊括其中,写了一部分发现几乎不可能,所以还是分开来写吧.
这一篇是第一篇,主要介绍一些基础的概念,包括同步、锁、原子类型以及非阻塞同步等,后续计划介绍concurrent包下面的一些类的使用和具体实现.
Java的并发主要有两个方面,一个是内存可见性(Memory Visibility),另一个是操作原子性(Atomicity).
我们先以一个单例设计模式为例来说明.
public class Singleton {
//private constructor
private Singleton() {}
private static volatile instance;
//double check
public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
instance = new Singleton()
}
}
}
return instance;
}
}
看到上述的单例模式写法,大家可能会有几个疑问:
- 为什么要用volatile关键字修饰instance
- 使用synchronized的目的是什么
- 为什么要做double check
下面依次解答.
- volatile关键字的作用
- 一个是保证内存可见性,通常情况下,一个变量即可能存储在CPU的缓存里,也可能存在线程的工作内存中,而valotile修饰的变量,是保存在主内存中的,这样它的变化对所有的线程可见.
- 另一个作用是不允许指令的重排序,如果没有强调happens-before的关系,则JVM会随心所欲的重排序.
以这个单例模式为例,调用类的构造函数分为三个步骤:
- 分配内存
- 初始化对象
- 将引用指向分配好的内存
如果将上述顺序打乱,很可能使用方拿到一个没有初始化完成的对象,从而导致不可预知的事情发生.
- synchronized的作用
synchronized有两种形式,一种是修饰方法的,包括静态方法和非静态方法,另一种则是修饰代码块,但是需要指定同步的对象.虽然两种形式不同,但本质是一样的,修饰方法的默认将对象的this或者class对象作为同步对象.- 被synchronized修饰的代码块或者方法,当线程调用时,会获取到锁,因为是基于monitor实现的,也可以叫做monitor lock.该锁只能有一个线程获取,其他线程如果要获取,必须等到当前线程释放锁,否则就会被block住
- 被synchronized修饰的代码,同一时间段,只能又一个线程访问,这有点类似于线程限定,将操作限定在一个线程中,自然不存在同步的问题
- synchronized是可重入的,即一个线程已经获取到该锁,如果执行该类的其他synchronized方法时,可以获取该锁,而不会被block
- double check的意义
考虑这样一种情况,两个线程A和B,同时调用getInstance方法,第一次检查,都发现instance为null,因为第一次检查没有同步,两个线程都可以调用,接下来假设A拿到了锁,并完成了对象的初始化,并释放了锁.然后B从被Block状态被唤醒,进入同步代码块,如果它不检查当前对象的有效性,那么它将重复的初始化对象.这就是我们为什么要用双重检查的原因.
上面是从一个单例模式的角度,来说明一些并发相关的知识点.
下面系统的对Java并发的相关知识进行介绍.
概念
线程及其优势和风险
线程是CPU能够调度的最小单元,所以一个线程只能同时跑在一个CPU核心上,一个单线程应用也就只能使用一个CPU核心(同一时间).
优势
- 充分利用多核CPU
- 简化事务处理模型
- 简化异步事件处理
- 用户界面响应更快
将GUI相关的放在单线程中,其他耗时操作放到子线程中,这样能够让GUI线程响应迅速,避免被阻塞
风险
Java的对线程的原生支持,以及统一的内存模型,让“write once, run-anywhere”成为可能.(不像KMM(Kotlin Multiplaform Mobile),在Android和iOS上的内存模型有差异,导致在Andorid正常的代码,在iOS会异常.)
- 安全隐患
单线程环境下正常的程序,在多线程环境异常.安全不能够妥协. - Liveness隐患
Liveness是指期望的结果,最终发生了.而隐患在于程序永久性的卡在当前状态,不能取得进展.有一下三种情况:- deadlock
一个线程拥有某个其他线程需要的资源,同时等待另一线程拥有的资源,并且只有当获取到不可能获取的资源后,才能释放.
有以下几种情况:- 锁顺序死锁
- 动态锁顺序导致的死锁
- 资源死锁
- starvation
线程永久性的无法获取需要的资源,最常见的是CPU时间.常见的原因是不恰当的使用线程优先级 - livelock
线程并没有被Block, 而是因为屡战屡败,不断重试的任务一直失败,而不能取得进展.
- deadlock
-
性能隐患
期望的结果最终发生了,不一定足够.性能问题范围较广,包括糟糕的响应时间、产出、资源消耗以及扩展性.线程切换对资源消耗很大,需要暂停当前的线程,并运行另一个线程,涉及到的操作有:保存恢复运行时上下文、CPU时间消耗在线程切换而不是运行.如果在线程间共享数据,必须使用同步,而同步会阻止编译器优化(比如使用volatile),缓存失效并因为同步而占用共享内存总线.
线程安全
我们说一个类是线程安全的,是指当用多线程访问时能够准确无误,符合预期,无论运行时环境如何调度和交叉执行,也不需要在调用端增加额外的同步或者其他的协调.
正确是线程安全的前提,如果一个应用在单线程情况下,都不是正确的,那么它不可能在多线程环境下线程安全.
无状态的对象始终是线程安全的.因为没有数据需要在线程间共享.
想要线程安全,我们有三种方式:
- 将操作限定在单一线程中
- 将共享的对象变成只读
- 恰当的使用同步
原子性
不可分割的操作,一次完成.
JMM(Java Memory Model)要求获取和存储操作,必须是原子的,但对于没有用volatile修饰的long和double是例外, JVM允许对64位值的读写,可以作为两次32位的操作.
-
race condition
当一个计算的正确性依赖相关的时机,或者依赖运行时多线程的交错,我们就说发生了race condition.换一个说法,即正确的结果依赖幸运的时机.最常见的情形是check-then-act, 当我们依赖一个可能有问题的当前状态,然后决定下一步做什么.
还有一个情形是read-modify-write,比如自增操作.这里需要注意一下race condition和 data race的不同.
-
data race
当一个变量可以被多个线程读取,同时也可以被至少一个线程写,但是读和写没有被happens-before确定好顺序.
一个被正确同步的程序,意味着所有的操作都有一个确定的全局的顺序. -
Happens-before
如果没有确定happens-before关系,JVM则会根据各自的实现任意重排序.以锁的释放和获取为例,假设线程A释放锁M后,线程B获取到该锁,happens-before体现在线程A的锁M释放前的所有操作,对线程B获取锁M后都是可见的.
有以下常见的规则:
- 程序顺序. 线程中的任意一个动作happens-before程序既定顺序中所有的后续动作
- monitor lock规则. 一个monitor锁的解锁操作 happens-before随后的所有加锁操作
- volatile变量规则. 一个对volatile变量的写操作 happens-before随后的所有对该变量的读操作
- 线程启动原则. Thread.start() happens-before所有启动后线程中的动作
- 线程终止原则. 线程中的任何操作 happens-before其他任意线程检测到该线程已经终止,无论是Thread.join的成功返回,还是Thread.isAlive返回false.
- 中断原则. 一个线程中断其他线程 happens-before该线程检测到自己被中断
- Finalzer原则. 对象的构造函数的结束happens-before该对象finlizer的开始
- Transitivity. 如果A happens-before B, B happens-before C, 那么 A happens-before C.
锁
原生锁
同步块(synchronized block),是Java提供的一种内置的锁机制,用来实现原子性.
一个同步块包含两个部分,一个是作为锁的对象的引用,另一个是被锁保护的一块代码.
同步方法则是对同步块的简写,同步代码块是整个方法,而锁是该方法被执行的对象.(静态同步方法使用Class对象作为锁).
每一个Java对象都可以作为一个锁,这些内置的锁被叫做原生锁或者monitor锁.
当线程进入synchronized块之前,会自动获取到锁,离开后,无论正常离开还是异常,会自动释放.所以获取原生锁的唯一方式,是进入同步块或者同步方法.
Java中的原生锁是互斥锁,意味着同一时间,最多只有一个线程能拥有锁.如果线程A尝试获取一个被线程B拥有的锁,A会被block住,如果B一直不释放锁,则A永远等待.
因为intrinsic锁是可以重入的,所以当线程尝试获取一个已经拥有的锁,是可以的.它通过在锁上附加计数以及所属线程来实现的.
当锁计数为0时,意味着当前锁是未被获取的,当线程获取之前未获取的线程时,count+1,并且JVM会记录锁的线程所有者.当同一个线程再次获取该锁时,count+1;当锁被释放时,count-1,当count减到0,说明锁被完全释放.
可重入意味着Java中,锁是基于线程的,而不是基于调用的.(pthread的mutexes是per-invocation).
显式锁
在Java 5.0之前,我们只有synchronized和volatile来协调对共享数据的读写. 5.0开始多了一个选项:ReentrantLock.
ReentrantLock并不是用来替代原生锁的,而是当某些情况下原生锁不好用时,可以作为一个可选方案.
和原生锁只能通过执行synchronized修饰的方法块获取不同,Lock接口提供了多种锁的获取方式,而且锁的获取和释放都是显式的.
public interface Lock {
void lock();
void unlock();
void lockinteruptible() throws InterruptedException;
boolean tryLock();
boolean tryLock(long timeout, TimeUnit unit) throws InterruptedException;
Condition newCondition();
}
锁的实现必须和原生锁一样有同样的内存可见性语义,但在锁的语义,调度算法,顺序确认和性能特点上都可以不同.
ReentrantLock实现了Lock接口,在很多方面都和synchronized关键字一样,包括互斥和内存可见性,同时也可重入,只是当锁不可用时,提供了更多的选择,从而更灵活.(如果去获取原生锁而不得,会被block,如果一直获取不到,会被一直block).
其灵活性体现在以下几点:
- 通过使用tryLock可以避免原生锁因获取不到锁而导致的死锁,当tryLock失败时,线程重新获取控制权,可以选择接下来的行为
- 使用可以被中断的锁,我们可以实现可以被取消的任务
- 可以选择是否公平的锁,如果是公平的锁,那么就按获取锁的时间排队,如果不公平锁,则允许插队,尝试获取锁时,锁刚好被释放,就可以优先执行.
- 实现非阻塞的锁
当需要上述灵活性时,可以考虑选择显式锁.但是不能因为性能原因,因为随着版本的更新,作为Java内置的原生锁的性能会提高,但是显式的并不会如此.
ReentrantLock是一个标准的互斥锁,和原生锁一样,但有的时候互斥并不是必须的.
互斥是一个保守的锁策略(后面会提到一种乐观的锁策略),其阻止了写/写和写/读的重叠,同时也阻止了读/读的重叠.在某些读比写多的多的情况下,允许多个读线程同时读数据会非常的好.所以在concurrent库中提供了ReadWriteLock,让一个资源可以被多个读者读,或者一个写者写,但是不能同时.(互斥)
可见性
假设有一个读线程和写线程,写线程先写入一个值,但不能确保读线程读到的就是刚写入的值.原因和JVM的内存模型有关,如果不使用同步,JVM允许编译器对操作重排序并且将结果缓存在寄存器中,而且允许CPU重排序以及在CPU核心自己的缓存中缓存数据,这样的话,这些操作对其他CPU核心就不可见了.
- volatile 关键字
Java提供了一种可选的,但弱一些的同步,它就是volatile关键字.
当一个变量被volatile修饰,就会知会编译器和运行时:该变量是被分享的,内存相关的操作不应当被重排序.同时,这种变量不可以缓存在寄存器和缓存中,这样就可以被其他的CPU核心看到,保证可见性.
简而言之,锁既可以保证原子性也可以保证可见性,但volatile只能保证可见性.
Volatile有其局限性,我们只应当在如下条件满足的情况下使用:
- 对变量的写操作不依赖当前值,或者确信对值的更新操作在单线程中
- 变量不需要和其他状态变量共同保证不变性,即不依赖其他变量
- 在不需要锁的其他情况
同步
同步包含原子性和可见性,确保heppens-before关系.
下面的几种方式都可以达到一定程度的或者完全的同步:
- synchronized
- volatile variables
- explicit locks
- atomic variables
Atomic变量 & 非阻塞同步
原子变量和非阻塞同步,是java.util.concurrent中的类的性能比synchronized强的主要原因,这里主要介绍其概念和原理,后面的文章中,会详细介绍.
最近的并发算法研究,都集中在非阻塞算法,其使用的底层的原子的机器指令,比如compare-and-swap去保证数据的完整性,而不是用锁.
非阻塞算法用着广泛的运用,包括操作系统以及JVM中用于线程和进程的调度,垃圾回收,实现锁和其他并发的数据结构.
锁的主要缺点在于,当多个线程竞争资源时,需要操作系统来协调,暂停和回复线程,包括上下文切换,这些操作都是非常的消耗资源的,同时会有导致长时间的中断. 如果该操作非常频繁,那么系统的主要资源都用在调度而不是运行程序了.
互斥锁是一种消极的,它假设的是最糟糕的情况,必须获取到合适的锁,保证其他线程不会干涉,言外之意即如果没有锁,那么其他线程必然会干涉.
当然还有一种更有效的方式,乐观的方式,即假设更新一个数据,假定可以无干扰的更新.这种方式依赖于碰撞检测.
多核处理器提供一些特别的用于管理并发读写共享变量的指令,早期的有原子的 test-and-set,fetch-and-increment等.现代的处理器都有类如原子的read-modify-write指令,比如 compare-ans-swap,或者load-linked/store-conditional.
- Atomic变量类
原子变量有更精细的粒度且比锁要更轻量级,同时对于实现高性能并发非常重要.
他们比volatile更适合做count之类的,因为他们的更新是原子的. - 非阻塞算法能够保证,任何时候,都至少有一个线程能够取得进展,而不是死锁.
总结
这篇文章, 从线程安全的单例模式开始,主要介绍了Java并发中的一些相关概念,包括同步、并发、锁、volatile关键字等,同时对比了基于锁和基于硬件指令的原子变量类.
在移动开发中,这些内容主要用于实现一些库,日常开发使用较少.在Kotlin的coroutine出来后,它比线程更轻量级,但存在同样的问题,因为其也是基于线程实现的.
后续的文章会继续介绍一些并发集合类等一些java.util.concurrent包下面的常见类.
参考
- 《Java Concurrency in Practice》