并发编程
并发编程
并发三个核心问题
并发编程领域可以抽象成三个核心问题:分工,同步和互斥。
分工很好理解,java sdk并发包里的executor, fork, join,future等本质都是一种分工方式,除此之外,一些设计模式也是用来指导如何分工的,例如生产者-消费者模式等。
同步主要指的是线程之间的协作。一个线程完成了工作,怎么通知别的线程执行后续的工作而已。例如,用Future可以发起一个异步调用,当主线程通过get()方法取结果时,主线程就会等待,当异步执行的结果返回时,get()方法就自动返回了。主线程和异步线程之间的协作,Future工具类已经帮我们解决了。除此之外,Java SDK里提供的CountDownLatch、CyclicBarrier、Phaser、Exchanger也都是用来解决线程协作问题的。工作中遇到的线程协作问题,基本上都可以描述为这样的一个问题:当某个条件不满足时,线程需要等待,当某个条件满足时,线程需要被唤醒执行。
互斥,我们一般也称为“线程安全”。在并发的场景下,多个线程同时访问共享变量的时候,结果是不确定的。而导致不确定的主要源头是可见性,有序性和原子性的问题。为此,java引入了内存模型,内存模型提供了一系列的规则,用来避免可见性和有序性,但是解决线程安全问题的核心还是互斥。
互斥就是同一时刻只允许一个线程访问共享变量。
实现互斥的核心就是“锁”。Java语言里synchronized、SDK里的各种Lock都能解决互斥问题。虽说锁解决了安全性问题,但同时也带来了性能问题,那如何保证安全性的同时又尽量提高性能呢?可以分场景优化,Java SDK里提供的ReadWriteLock、StampedLock就可以优化读多写少场景下锁的性能。还可以使用无锁的数据结构,例如Java SDK里提供的原子类都是基于无锁技术实现的。
除此之外,还有一些其他的方案,原理是不共享变量或者变量只允许读。这方面,Java提供了Thread Local和final关键字,还有一种Copy-on-write的模式。
使用锁除了要注意性能问题外,还需要注意死锁问题。
可见性、原子性和有序性
为了平衡cpu和内存之间速度的差异性,目前计算机体系结构,操作系统和编译程序作出了以下贡献:
- cpu增加了缓存;
- 操作系统增加了进程,线程,以分时复用cpu,进而平衡cpu和io设备之间的速度差异;
- 编译程序优化指令的执行次序,使得缓存能够更加合理地利用。
但是也由此引发了很多诡异的问题
源头之一:缓存导致的可见性问题
可见性:一个线程对于共享变量的修改对另一个线程而言是立时可见的。
多核时代每个cpu都会有属于自己的缓存,那么当多个线程在不同的cpu上执行时,这些线程操作的是不同的缓存。当它们同时读取内存中的变量,写入各自的缓存,之后又写会内存,本是两次操作,可能只有一次操作是有效的。这实际上是硬件程序员给软件程序员挖的“坑”。
最经典的问题:启动两个线程对一个共享变量a进行+1操作,操作执行10000次,预期中的a=20000,实际上a的值会在10000~20000之间。
源头之二:线程切换带来的原子性问题
原子性:一个或多个操作在cpu执行过程不能被中断
多线程分时复用在操作系统的发展史上有着里程碑意义。
Java并发程序都是基于多线程的,自然会涉及到线程切换。但是实际上,操作系统在做切换时,是可以发生在任何一条cpu指令执行完成时,而不是高级语言的一条语句。而高级语言中的一条a ++语句实际上至少需要3条cpu指令:
- 将变量a从内存加载到cpu的寄存器中;
- 在寄存器中执行+1
- 将结果写到内存中,当然由于cpu有缓存机制,所以写到的可能是缓存而不是内存。
源头之三:编译优化带来有序性问题
实际上,代码并不会按照我们编写的顺序执行,编译器为了优化性能,有时候会改变程序中语句的先后顺序,但是这有时会导致意想不到的bug。
最经典的问题:双重检测创建单例
public class Singleton{
private static volatile Singleton singleton;
public static Singleton getInstance(){
if(null == singleton){
synchronized(Singleton.class){
if(null == singleton){
singleton = new Singleton();
}
}
}
return singleton;
}
}
为什么这里一般会强调使用volatile关键字,因为new一个对象实际上的操作是:
- 分配内存;
- 在内存上初始化 Singleton对象;
- 将地址赋值给singleton变量
而编译器优化后的步骤可能是1->3->2。
那么优化后就可能导致一些问题,例如线程1判断当前变量为null,获取到了锁,然后new一个对象,执行到了步骤2,线程2进入发现此时变量不为null,直接返回一个还没有初始化的变量。这时候就有可能触发空指针。
其实无论是缓存,线程还是编译优化,目的都是相同的,系统能够提高程序的性能,但是一项技术在解决一个问题的同时,必然会带来另一个问题,所以在采用一项技术的同时,一定要清楚它带来的问题以及如何规避。
Java内存模型:Java如何解决可见性和有序性
Java内存模型的主要目标是定义了程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层的细节。
Java内存模型中规定了所有的变量都存储在主内存中,每个线程还会有自己的工作内存,线程的工作内存中保存了该线程使用到的主内存的变量拷贝,线程对变量的所有操作都必须在工作内存中操作,而不能直接读取主内存中的变量。不同线程无法访问对方工作内存中的变量,线程之间变量值的传递需要在主内存中完成。
为了解决可见性和有序性,内存模型采用的方法就是给程序员提高按需禁用缓存和编译优化。
Java内存模型提供了一套很复杂的规范,具体来说包括volatile,synchronized和final关键字,以及六项happens-before规则。
volatile
volatile关键字:禁用缓存。
Java1.5版本以后,就在volatile语义上进行了增强:happens-before原则。
happens-before原则
核心语义:前面一个操作对后面一个操作是可见的。
该原则约束了编译器优化行为,优化后必须遵守happens-before原则。
- 程序的顺序性:按照程序顺序,前面的操作对后面的操作是可见的;
- volatile变量的写操作对读操作是可见的;
- 传递性:A Happens-Before B,且B Happens-Before C,那么A Happens-Before C
- 管程中锁的规则:管程是一种同步原语,java中的synchronized就是对管程的实现。解锁对加锁是可见的;
- 线程start()规则:在主线程中开启子线程,子线程能够看见主线程在开启子线程之前的所有操作;
- 线程join()原则:主线程调用join之后,子线程完成后,子线程的所有操作对主线程是可见的。
互斥锁
原子性问题的源头是线程切换,但是通过禁止cpu中断来禁止线程切换在多核时代并不可行。为了能够保证“互斥”,即同一时刻只有一个线程执行,就需要引入锁的概念。
加锁不仅需要关注加锁操作本身,还需要关注被锁对象。锁和资源之间的关系是1:n。
我们不能使用多把锁保护一个资源,但是可以用一把锁保护多个资源。
在保护多个资源时,需要区分资源之间是否有关系,如果没有关系,那么用不同的锁保护不同的资源进行精细化管理能够提升性能,也叫做细粒度锁。
而对于有关联的多个资源,需要共享一把锁。
synchronized
synchronized是java语言提供的一种锁实现,不仅可以修饰方法,还可以修饰代码块。
加锁和解锁操作是默认加上的,不需要使用者显式地操作。
死锁
细粒度锁可以提高并行度,是性能优化的一个重要手段,但是使用细粒度锁是有代价的,这个代价就是可能导致死锁。
死锁:一组互相竞争资源的线程互相等待,导致永久阻塞的现象。
解决死锁最好的办法还是规避死锁,破坏死锁的四个必要条件之一,就可以很好地规避。
死锁的四个必要条件:
- 互斥;
- 持有等待;
- 不可剥夺;
- 循环等待;
在代码层面解决死锁:
- 破坏持有等待:一次申请所有需要的资源,可以创建一个class来管理所有的资源,每次申请,释放资源都需要通过这个类。那么可以在所有申请资源的类中都持有一个单例的资源管理类。
- 破坏不可剥夺:Java的synchronized并没有解决这个问题,在申请资源的时候,如果申请不到,线程会一直等待,并且不会释放线程已经占有的资源。目前lock可以解决这个问题。(大概率是一定时间后释放资源)
- 破坏循环等待:破坏循环等待,只需要控制申请资源的次序就好。
“等待-通知”机制优化循环等待
“等待-通知”机制也可以规避死锁问题,可以用来破坏持有等待条件。
在尝试获取互斥资源时,能够进入临界区的只有一个线程,其余线程会在等待队列1中,进入临界区持有资源时,可以判断是否满足执行条件,如果不满足,调用wait()进入等待阻塞状态,放弃资源,进入对象锁的等待队列;此时其他线程有机会获取互斥资源进入临界区;同时在满足一定条件时,调用notify()/notifyall()唤醒线程。
尽量用notifyall(),因为notify()只会唤醒一个线程进入锁池竞争资源;而notifyall()会唤醒所有线程进入锁池竞争资源,当然只是有竞争资源的资格,并一定能够保证进入临界区,竞争失败的线程遗留停留在锁池,等待下次竞争机会。
安全性、活跃性和性能问题
并发编程是一个复杂的技术领域,微观上涉及到原子性,可见性和有序性问题;宏观上则表现为安全性,活跃性和性能问题。在安全性上,我们需要注意数据竞争和竞态条件。
安全性
在存在共享数据且数据会发生变化时,或者说多个线程会同时读写同一数据,就无法保证线程安全,线程安全的本质就是正确性,即程序按照我们的期望执行。
为了解决线程安全问题,我们一类做法是采用不共享数据或者数据不发生变化,例如线程本地存储、不变模式等。一类做法是在面对数据竞争和竞态条件时采用互斥,使得并行操作在一定范围内串行。
活跃性
活跃性,顾名思义就是某个操作执行不下去。
典型的活跃性问题:死锁,活锁,饥饿。
死锁问题在此不再赘述。
活锁:没有线程阻塞,但是仍然执行不下去。解决活锁很容易,只需要尝试等待随机时间即可。分布式一致性算法-raft中也用到了这个。
饥饿:所谓饥饿就是线程因为无法访问所需资源而无法执行。解决饥饿方案很简单:
一是保证资源充足,二是公平分配资源,三是避免持有锁的线程长时间执行。一和三比较难解决,我们一般是采用方案二解决饥饿问题,比如采用公平锁。
性能问题
锁的范围过大就会带来性能问题,范围过小就可能存在线程安全问题,这个分寸需要我们自己来权衡。
解决性能问题:
方案一:采用无锁和数据结构,比如thread local copy-on-write,乐观锁等;Java并发包中的原子类等;
方案二:减少锁持有时间。互斥锁的本质是并行程序串行化,所以为了增加并行度,就需要减少持有锁的时间,比如采用细粒度锁,比如分段锁,读写锁等。
度量性能的三个比较重要的指标:
- 吞吐量:单位时间内能够处理的请求;
- 并发度:同时处理的请求量;
- 延迟:从发出请求到收到相应的时间。
管程
管程,对应的英文是monitor,就是管理共享变量以及对共享变量的操作过程,使得支持并发。翻译成Java语言就是,管理类的成员变量和成员方法,使得这个类是线程安全的。
目前管程有三种模型,分别是hasen, hoare 和木器啊Java实现管程所参考的MESA模型。
MESA模型
在并发领域有两个核心问题:并发和同步。
MESA模型解决互斥的思路:将共享变量和对共享变量的操作封装起来。
解决同步的思路:引入条件变量的概念,每个条件变量都有一个等待队列,并结合wait, notify。
在MESA模型中使用wait()的编程范式:
while(条件不满足){
object.wait();
}
操作;
object.notifyAll();
在MESA管程模型中,notify的线程不会立即执行,而是从等待队列进到入口等待队列,所以不需要考虑notify的位置。但是这里可能会导致,被唤醒的线程在执行的时候,条件不满足了,所以需要循环检验条件变量。
线程的生命周期
在Java中线程的生命周期有6种状态:
从new到runnable状态
Java刚创建出来的thread处于new状态,当分配到cpu时就变成了runnable状态。
目前thread有两种创建方式,一种是继承Thread类,一种是继承Runnable接口。
从new状态执行start()方法就可以到runnable状态。
// 自定义线程对象
class MyThread extends Thread {
public void run() {
// 线程需要执行的代码
......
}
}
// 创建线程对象
MyThread myThread = new MyThread();
myThread.start();
// 实现Runnable接口
class Runner implements Runnable {
@Override
public void run() {
// 线程需要执行的代码
......
}
}
// 创建线程对象
Thread thread = new Thread(new Runner());
thread.start();
从runnable到blocked状态
触发这种场景目前只有线程等待锁。
由于jvm并不关心操作系统层面的调度,所以在jvm看来无论是等待cpu还在io阻塞没有区别,都是在等待某种资源,所以都处于runnable状态。
而我们平时所谓的Java在调用阻塞式API时,线程会阻塞,指的是操作系统线程的状态,并不是Java线程的状态。
runnable与waiting的状态转换
以下三种场景会触发:
- 在持有synchronized对象锁的时候,调用wait()方法是;
- 调用无参Threada.join(),执行该语句的线程会等待Threada线程执行完毕,这其实是一种线程同步的方法,一般用来主线程和子线程之间进行通信。
- LockSupport.park(),当前线程会阻塞,不会放弃当前持有的线程。调用LockSupport.unpark(Thread thread)可唤醒目标线程,目标线程的状态又会从WAITING状态转换到RUNNABLE。
RUNNABLE与TIMED_WAITING的状态转换
TIMED_WAITING和WAITING状态的区别,仅仅是触发条件多了超时参数。
从RUNNABLE到TERMINATED状态
目前stop()方法已不建议使用,目前停止线程主要采用 interrupt()方法。
因为stop()方法会直接停止线程,比如一个线程持有锁,停止线程,就不再会释放锁。
而相对interrupt()就比较温柔,目前主要有两种方式中断线程,一种是异常,一种是主动捕获。
当线程A处于WAITING、TIMED_WAITING状态时,如果其他线程调用线程A的interrupt()方法,会使线程A返回到RUNNABLE状态,同时线程A的代码会触发InterruptedException异常。上面我们提到转换到WAITING、TIMED_WAITING状态的触发条件,都是调用了类似wait()、join()、sleep()这样的方法,我们看这些方法的签名,发现都会throws InterruptedException这个异常。这个异常的触发条件就是:其他线程调用了该线程的interrupt()方法。java.nio.channels.InterruptibleChannel上时,如果其他线程调用线程A的interrupt()方法,线程A会触发java.nio.channels.ClosedByInterruptException这个异常;
而阻塞在java.nio.channels.Selector上时,如果其他线程调用线程A的interrupt()方法,线程A的java.nio.channels.Selector会立即返回。
上面这两种情况属于被中断的线程通过异常的方式获得了通知。还有一种是主动检测,如果线程处于RUNNABLE状态,并且没有阻塞在某个I/O操作上,这时就得依赖线程A主动检测中断状态了。如果其他线程调用线程A的interrupt()方法,那么线程A可以通过isInterrupted()方法,检测是不是自己被中断了。
创建多少线程是合适的
为什么要使用多线程?
使用多线程的目的:
- 降低延迟:降低发出请求到收到结果的时间;
- 提高吞吐量:提高单位时间内能处理请求的数量;
多线程的应用场景
“降低延迟,提高吞吐”基本有两个方向:
- 优化算法;
- 将硬件的性能发挥到极致;
而将硬件的性能发挥到极致,更具体点就是提高io和cpu的利用率。
而多线程就能解决上述的问题。
在单核时代,多线程主要是用来平衡cpu和io设备的。如果程序只有cpu计算,没有io,那么多线程反而会更慢,因为需要不断地切换线程。
而在多核时代,这种cpu密集型计算,就可以采用多线程,因为可以降低延迟。
创建多少线程是合适的
cpu密集型
对于cpu密集型,多线程本质上提升多核cpu的利用率。所以理论上是创建“cpu核数”个线程就可以了,但是由于线程会偶发性因为某些原因阻塞,所以在工程上一般设置为"cpu核数+1"个线程。
io密集型
由于io操作时,cpu时空闲的,所以只需要创建io耗时/cpu耗时个线程就可以,同样在工程上我们可以创建(io耗时/cpu耗时)+1 个线程。
在多核时代:cpu核数 * [(io耗时/cpu耗时)+1 个线程]
局部变量是线程安全的
每个方法在调用栈中都有自己的独立空间,称为栈帧,每个栈帧都有对应方法需要的参数和返回地址以及局部变量,一个java方法的执行伴随着一个栈帧从入栈到出栈,栈帧和方法是同生共死的。
而每个线程都有自己独立的调用栈,所以不会存在线程安全的问题,这种解决线程安全的方法叫做线程封闭。比如数据库连接池中获取链接connection,就是一个线程获取以后,不会被其他线程获取,所以jdbc并没有要求connection是线程安全的。
面向对象和并发编程
在Java中,面向对象可以使得并发编程变得更简单。
- 封装共享变量:将共享变量作为对象属性封装在内部,对所有的公共方法制定并发访问策略,对于一些不会发生变化的共享变量,用final修饰;
- 识别共享变量之间的约束条件;
- 制定并发访问策略:
1. 避免共享:利用线程本地存储以及为每个任务分配独立的线程;
2. 不变模式;
3. 管程和其他同步工具:优先使用工具,比如sdk中的工具类,迫不得再使用并发原语,优先保证安全,避免过早优化。
持续阅读中……