java并发编程的艺术学习笔记

java并发编程的艺术学习笔记

上下文切换

cpu通过给线程分配时间片,时间片一般是几十毫秒。在线程切换的时候,需要在切换前保存上一个任务的状态,以便下次切换回是可以再加载这个任务。这样一个任务从保存到在加载的过程就叫做上下文切换。多线程虽然能将多核cpu物尽其用,但是线程之间的切换也是需要时间和成本的。在并发编程中,将代码执行速度加快的原子是将代码中串行执行的部分变成并发执行,但是如果将串行执行的部分变成并发执行,因为受限于资源,仍然在串行执行,那程序不仅没有加快,还会变慢,因为增加了上下文切换和资源调度的时间。

java的内存模型(JMM)

JMM模型定义了一个主内存,主内存存放各个线程的共享变量。而各个线程自己有自己的一个叫本地内存的内存。这个本地内存的作用是缓存主内存的共享变量,于是在各个线程里都有其线程所需的共享变量的副本。而线程A修改共享变量让线程B获取,流程则是线程A修改本地内存A里的共享变量的值,然后把本地内存A的共享变量写到主内存。线程B在读取此共享变量时先往主内存读取共享变量到本地内存B,然后才从本地内存B中读取共享变量的值,完成数据传递。当然,JMM只是个概念模型,实际上主内存主要是java堆内存,本地内存是各种缓存,写缓冲区,寄存器等。

重排序

编译器和cpu为了提高代码执行效率,会对我们写的代码进行重新排序。对我们写的代码进行重新排序听上去好像很可怕,代码不就乱套了吗,但实际我们的代码还是运行的好好的,这证明编译器和cpu并不是为所欲为地重排序。

这里先说编译器的重排序。编译器是jvm的一部分,编译器要选那些代码重排序,怎么重排序是可以自己说了算的。书上说了一通什么happens-before,什么as-if-serial语义,什么程序顺序规则,我即没能很理解这些概念,也感觉并没有有助于理解,不理解也不妨碍其他内容的理解,所以不介绍了。按我自己的总结,编译器的重排序原则是:在单线程下,只要不影响运行结果的重排序都是可重排序的。额,好像是废话,举个例子。

1
2
3
double pi=3.14;
double r=1;
double area=pi*r*r;

在这里有三行代码,我们写的时候潜意识默认他像脚本一样,执行完第一行double pi=3.14;,再执行第二行double r=1;,最后执行第三行double area=pi*r*r;,最后能算出正确的面积。那如果要你给这三行重新排序,怎么排序才不影响结果呢?显然只能把第一第二行调换这一种选择。这就叫在单线程下不影响运行结果的重排序。这里要强调是在单线程下,如果在多线程下都还能自动同步好,还用学啥同步。

数据依赖性

为什么编译器不会把1和3或者2和3行代码调换呢?因为1,3和2,3之间存在数据依赖,3依赖于1的pi,同理3也依赖于2的r。1和2会对数据进行创建或者是修改赋值,这些变量正是3所依赖的,那么1,2就会在3之前执行。但是1和2之间没有数据依赖关系,所以他们谁先谁后没关系,就任编译器重排序了。

内存屏障

那cpu呢,不同的cpu对命令重排序的标准各不一样,规则也不由得jvm的影响,因此有内存屏障这种东西来让jvm禁止cpu的一些不符合需求的重排序。内存屏障根据读/写的搭配有四种屏障。

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 确保Load1数据的装载先于Load2及所有后续转载指令的装载
StoreStore Barriers Store1;StoreStore;Store2 确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储
LoadStore Barriers Load1;LoadStore;Store2 确保Load1数据装载先于Store2以及后续存储指令刷新到内存
StoreLoad Barriers Store1;StoreLoad;Load2 确保Store1数据对其他处理器可见先于Load2及后续装载指令的装载。StoreLoad Barriers会使该屏障之前所有内存访问指令完成之后,才执行该屏障之后的内存访问指令

不同的cpu的不同重排序规则,所以jvm会根据不同的cpu在适当的地方插入内存屏障来禁止cpu的某些重排序

cpu Load-Load Load-Store Store-Store Store-Load 数据依赖
SPARC-TSO N N N Y N
x86 N N N Y N
IA64 Y Y Y Y N
PowerPC Y Y Y Y N

volatile

个人总结,volatile的含义是:当一个共享变量被声明为volatile时,如果这个共享变量被某一个线程修改了,那么其他线程将会能立即获取到这个修改后的值,但也仅此而已。这里有两个问题,为什么如果一个共享变量不声明为volatile,被某一个线程修改了,其他线程不能立即获取到这个修改后的值?第二是“但也仅此而已”是什么意思?

第一个问题先回忆一下JMM的模型。这个模型在共享变量在各个线程之间通讯流程有个问题,由于有本地内存的存在,如果线程A要修改共享变量的值,是只能把在自己的本地内存里修改共享变量的副本的值,那万一本地内存的共享变量迟迟不跟新到主内存,那么线程B就算有意到主内存里检查共享变量是否以及被修改,发现的还是原值,这样线程A和线程B就不同步了。而volatile就是解决这个问题。当一个共享变量的被声明为volatile时,如果线程A修改了共享变量,那么JMM将会立即把本地内存的值刷新到主内存。当线程B读取共享变量时,JMM会把线程B的对于本地内存置为无效,线程B将会从主内存读取共享变量。

第二个问题是要强调volatile只在读/写里同步。如果i是volatile共享变量,执行i++是没法保证同步的,因为i++是分读取i,计算i+1,把i+1赋值给i这三步。第一和第三步是同步的,但是如果在执行第二步时其他线程修改了i的值就错了。

除此以外,volatile还有禁止一定的重排序的功能。例如volatlie写前面会有StoreStore屏障,使得其前面的所有普通写操作既不与其重排序,并且被刷新到主内存,被任意线程可见。

synchronized

当线程是否锁的时候,JMM会把该线程对应的本读内存中的共享变量刷新到主内存中。当线程获取锁的时候,JMM会把该线程对应的本地变量置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。

final

JMM禁止编译器把final域的写重排序到构造函数以外,也会在cpu插入StoreStore屏障来禁止cpu做这种重排序。因此,任意线程通过正常渠道获取到某个对象时,能确保这个对象的final变量以及被正确初始化过,而普通变量则无法保证。这里又有两个问题,为什么普通变量不能确保被正确初始化,什么叫正常渠道。

第一个问题好讲,如果上面理解的话,一个构造函数完成,返回对象,跟这个对象里的普通变量是没有函数依赖的,因为只是返回了对象,还没有通过对象获取对象的变量。所以构造方法里的普通变量可能会在构造函数返回之后才初始化,当然在单线程下也会在引用这个变量之前初始化完成。这了就有个空隙了,在构造方法返回到引用变量之间,可能会导致其他线程已经可以获取到对象,但是通过对象引用变量却是还没有被正确初始化的,导致同步问题。而final确保变量的初始化困在构造函数里,所以构造函数完成,获取到对象之后,final变量也一定以及被正确初始化。

而所谓的正常渠道是有关于对象在构造函数里溢出的问题,简单来说就是在构造函数里把this传递给了其他线程。this都还在构造函数里,没有初始化完成,就被其他线程拿来引用,显然这样子连final也无法确保变量已经被正确初始化。

双重检查锁定与延迟初始化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Test{
private static Object o;

pubcli static Object getObject(){//多个线程调用此方法
if(o==null){
synchronized(Test.class){
if(o==null){
o=new Object();
}
}
}
return o;
}
}

以上代码就是错误的双重检查锁定,代码设计者希望调用o的时候才初始化,但又不想每次调用getObject方法都进行加锁和解锁,就先判断o是否为空,空才锁定来初始化。但是我们假设,第一次调用是线程A,那么自然A就会进入同步快初始化o,但如果o有普通变量,那么这些普通变量对于线程A来说可能会在new Object();到方法返回之间才初始化,但是这个阶段即是是对于A来说,还是对于其他线程来说,o都是!=null的。那么可能在A运行到new Object();到方法返回之间,o的普通变量还没初始化,但是这是线程B调用方法,发现o!=null,就直接拿还没完全初始化完成的o来调用了。正确的双重检查锁定应该为o加上volatile。为什么加上volatile就可以呢?因为o=new Object();是volatile写操作,volatile写要确保其之前的读写操作已经完成并且任意线程可见,这就包括了构造函数里的全部初始化写操作。

java的原子操作类

java在Atomic包里提供了13个原子操作类,分四类,原子更新基本类型,原子更新数组,原子跟新引用和原子跟新属性。因为方法都是差不多的,这里就只介绍AtomicInteger类,原子更新整型。主要方法有:

  • int addAndGet(int delta) 将其值与delta相加,返回相加结果
  • boolean compareAndSet(int expect, int update) 如果其值为expect,则把值设为update
  • int getAndIncrement() 自增1后返回自增前的值
  • int getAndSet(int newValue) 把值设为newValue,返回旧值

乍看这些方法并没什么特别,但特别在与这些方法都是原子操作的,什么?例如compareAndSet方法,按逻辑应该有三步,取出原值,比较原值与expect,设置值为update。如果我们自己用if语句什么的实现这个逻辑,三步里就会有两个空挡,这两个空挡在多线程下是不安全的。而这个方法不同,虽然逻辑上是三步,但他一步完成(也就是为什么叫原值操作),没有任何空挡导致线程安全问题。因此我们可以构建一个线程安全的计数器,上面的getAndIncrement也是这么实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Test{
private static final AtomicInteger ai=new AtomicInteger(0);
public static void increment(){
while(true){
int i=ai.get();
int update=i+1;
if(ai.compareAndSet(i,update)){
return;
}
}
}
public static int getCount(){
return ai.get();
}
}

自旋锁与CAS

所谓自旋,就是一个做“无用功”的循环,做这个循环来“浪费”时间,等待其他线程释放锁。一般,会顺便在这个循环里不断尝试获取锁,这就是自旋锁。而CAS是自旋锁与volatile,原子操作类的结合产物。思路是这样,既然原子操作类可以线程安全地修改值,那如果这里拿一个整数作为标记,例如0代表没占用,1代表占用,那么哪个线程成功把值从0修改为1就获得了锁,在自旋中不断尝试修改值(尝试获取锁)。当然这里整数还需要用volatile来修饰,避免线程A以及把值改为1,但是线程B还以为值是0的问题。这样子的一个锁方案大体就做CAS。还有一个问题,如果某个线程一直不释放锁,那么其他线程的自旋就变成了死循环了,所以jvm会根据实际情况给CAS一设一个循环次数,超过循环次数就选择挂起,通过CAS获取锁失败。

synchronized与偏向锁、轻量级锁、重量级锁

在写代码的时候用到synchronized的时候,当然是假想有好多的线程去竞争,某个线程获得锁之后其他线程就挂起等待,这么一种方案,这种方案是重量级锁。由于我们只是假想线程间竞争是激烈的,那万一不激烈呢,所以还有synchronized另外两个方案偏向锁和轻量级锁。既然synchronized有三种方案,那就要找一个地方记住现在所使用的是哪一种方案,这个叫锁标志位,是在锁对象的对象头里的,占位2bit。

重量级锁

调过来,先说重量级锁。如上面所说,当某个线程获得锁之后其他线程就挂起等待,就解决问题了。但是java的线程是映射到操作系统原生线程之上,线程的阻塞和唤醒都需要系统来操作。并且还需要在用户态与内核态之间传递给许多变量、参数,会消耗好多时间,因此,不是不能用,并且也是蛮通用的,只是效率低而已,作为三种方案的最后保底的一种。

偏向锁

偏向锁是面对同步方法或者同步快其实实际上只有一个线程去访问的情况(个人理解其实多个线程也是可以的,只要不触发锁升级的条件,锁升级下面会说道)。偏向锁是怎么做的呢,首先看到,如上图所示,偏向锁有个存储一个叫线程ID的地方。一开始,当线程A想获取锁,第一会对比锁对象的那个线程ID是不是指向自己这个线程,由于是第一次,肯定对不上,那线程A就通过CAS尝试把锁对象的这个线程ID指向自己,又因为是第一次,先假设没线程跟A竞争,A自然顺利把线程ID指向自己,成功获取锁。第二次,线程A有像获取锁,一对比线程ID是指向自己,便又获取锁了。这样子对比一下就获取到锁成本会比较低。这时候,线程B出现了也想获取锁,按流程,发现线程ID不是自己,就用CAS尝试修改线程ID,但如果这次线程A还就都不释放锁,B将通过CAS获取锁失败被挂起,触发锁升级,偏向锁将会升级为轻量级锁。等待A释放锁之后的一个全局安全点(在这个时间点上没有字节码正在执行),线程A也会被挂起(网上说这个是个stop the World,但时间很短),如果线程A已经挂掉,则把锁标志位设为无锁状态,否则设置为轻量级锁。

轻量级锁

偏向锁升级为轻量级锁之后,某个线程想获取锁,jvm会在这个线程的栈帧里创建一个空间,这个空间用来存储锁对象的mark word。为什么要这么做的。因为轻量级锁下,线程获取锁的过程,就是把锁对象的mark word复制到之际的栈帧里,然后通过CAS把锁对象的mark word地址指向自己栈帧这个mark word。如果指向成功,那就成功获取到锁了。否则继续自旋尝试获取。当线程释放锁的时候,会通过CAS把栈帧的mark word复制回锁对象头里。如果复制成功代表没有竞争发送,如果复制失败,就会升级为重量级锁。

线程

线程的优先级

感觉线程的优先级基本上是忽悠人的没啥用的。java的线程优先级分十级,默认是5,级别越高越优先。但是实际上那个线程可以获取到cpu的使用权是系统实现的,系统的线程级别也各不一样。系统是可以完全不用理会java指定的线程优先级的,实际上也没啥用,不可以依赖于线程的优先级。

线程的状态

线程有六个状态,这东西不用背,理一理逻辑就有,一个线程刚刚创建是初始状态,创建完就运行是运行状态,运行完就是终止状态。当然运行并不是一帆风顺,被迫停下叫阻塞状态,主动不限时停下是等待状态,主动限时停下是超时等待状态。

Daemon线程

如果非Daemon线程都结束了,Daemon线程将会立即结束,所以Daemon线程即是在finally的代码也不一定会执行。设置Daemon线程方法是在线程执行start()方法前调用Thread.setDaemon(true)方法。

线程中断

中断线程可以执行此线程的interrupt()方法。线程检查自己是否被执行过interrupt()方法可以通过isInterrupted()方法。一般执行了interrupt()方法后isInterrupted()方法会返回true,但是如果该线程的某些会抛InterruptException的方法抛了InterruptException,在抛InterruptException之前会设置使得isInterrupted()方法返回false。不知道这样设置的意图何在。而安全地终止线程可以如下

1
2
3
4
5
6
7
8
9
private volatile boolean runable=true;
public run(){
while(runable && !Thread.currentThread().isInterrupted()){
...
}
}
public void down(){
runable=false;
}

等待/通知机制

1
2
3
4
5
6
7
8
9
10
11
12
//等待方
synchronized(锁对象){
while(条件不满足){
锁对象.wait();
}
其他逻辑
}
//通知方
synchronized(锁对象){
改变条件
锁对象.notify();
}

管道输入流/输出流

1
2
3
4
PipedWriter out=new PipedWriter();
PipedReaader in=new PipedReader();
out.connect(in);//输入管与输出管连接
//然后往输入管里写,输出管就能读了。

Lock锁

Lock是一个接口,其实现类有ReentranLock。Lock的使用与synchronized不同。synchronized的获取锁和释放锁是隐式的,而Lock是显式的。与synchronized相比,Lock还有可以尝试非阻塞获取锁,能中断获取锁和超市获取锁的功能。Lock的api

方法名 描述
void lock() 获取锁,调用该方法线程将会获取锁,当获取锁后,从该方法返回
void lockInterruptibly() throws InterruptedException 可中断获取锁,和lock()方法的不同之处在于该方法会响应中断,即在锁的获取中可以中断当前线程
boolean tryLock() 尝试非阻塞的获取锁,调用该方法后会立刻返回,获取成功返回true,否则返回false
boolean tryLock(long time, TimeUnit unit) throws InterruptedException 超时获取锁,当前线程在以下情况会返回 1、当前线程在超时范围内获取锁 2、当前线程在超时范围内被中断 3、超时时间结束返回false
void unlock() 释放锁
Condition newCondition() 获取等待通知组件,该组件和当前的锁绑定,当前线程只有获得了锁,才能调用该组件的wait()方法,而调用后,当前线程将释放锁

队列同步器

队列同步器(AbstractQueuedSynchronizer)是实现Lock的核心。其实它封装了CSA,通过一个整型作为一个红绿灯来指挥各个线程之间的同步。队列同步器看名字是个抽象类,它的方法有两类,一类是需要我们继承AbstractQueuedSynchronizer,根据需求重写的,另一类是提供给外部调用的模板方法,这些模板方法会调用我们重写的方法。我们实现的AbstractQueuedSynchronizer子类一般是作为Lock的实现类的内部类的,这就限制了队列同步器只能其外部的Lock类才能调用其模板方法。锁可以分为排他锁和共享锁,对应我们可以重写的队列同步器的方法有独占式锁的获取与释放方法和共享式的获取与释放方法两类。如果锁是排他锁,并且你的队列同步器只调用到独占式的模板方法,那就只需要重写独占式锁的获取与释放方法即可,共享式的同理。既然队列同步器封装了CAS,还要重写锁的获取与释放方法,AbstractQueuedSynchronizer类提供了三个方法以供调用。

getState()获取当前同步状态
setState()设置当前同步状态
compareAndSetState(int expect,int update)使用CAS设置当前状态,该方法能够保证状态设置的原子性

其代码又长又不知道怎么说,就懒得写了。

队列同步器的同步队列

队列同步器内部有个同步队列,这个同步队列存放着等待获取锁的线程,新的等待获取锁的线程会从队尾进入,只有队头线程有资格尝试获取锁,而后面的其他线程则是通过CAS不断检查自己的前一个结点是不是头结点(下一个到自己了),如果是就尝试获取锁,获取成功就进阶为头结点。要注意的就是线程进入队列要注意同步,以及各种CAS的实现。同样代码很长也不说了。

重入锁

重入锁指某个线程获取了某个锁,然后他能再次获取这个锁,这点synchronized是可以的,Lock实现除了上面的,就多一个判断一下,如果锁已经被获取,那再次来获取的这个线程是不是获取了锁的线程的判断就好。ReentranLock类已经实现。

读写锁

前面提到的重入锁是排他锁,就是锁只能由一个线程获取,而锁能由多个线程获取的叫做共享锁。读写锁就是共享锁。读写锁维护两个锁,一个读锁,一个写锁。读锁能让多个线程获取,共享的,而写锁是独占的。但是要注意一点,虽然这里是有读锁和写锁,但是是需要在同一个原子整型做标记。一个整型做两个标记信息可以通过按位切割使用的方法,就是整型有32位,高16位标记读状态,低16位标记写状态,通过位运算来修改标记。

LockSupport工具

LockSupport可以用来阻塞某个线程,有四个静态方法

方法 描述
void park() 阻塞当前线程,只有调用unpark(Thread thread)或者当前线程被中断,才能返回
void parkNanos(long nanos) 在park()方法的基础上增加了超时时间,单位为nanos纳秒
void parkUntil(long deadline) 阻塞当前线程,知道deadline(从1970年到现在的毫秒数)
void unpark(Thread thread) 唤醒处于阻塞状态的thread

Condition接口

Condition接口提供类似Object的方法,与Lock配合可以实现等待/通知模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Lock lock=new ReentratLock();
Condition condition=lock.newCondition();
public void conditionWait(){
lock.lock();
try{
condition.await();
}finally{
lock.unlock();
}
}
public void conditionSignal(){
lock.lock();
try{
condition.singal();
}finally{
lock.unlock();
}
}

ConcurrentHashMap

HashMap是线程不安全的,并发执行put方法会导致Entry的链表结构形成环形结构,Entry的next永远不为空,就产生了死循环。而HashTable虽然是线程安全的,但是效率低,读写不能同时进行,并且还是排它锁。ConcurrentHashMap通过锁分段技术提高并发访问率。ConcurrentHashMap由Segment[]组成,Segment由HashEntry[]组成。进入某个Segment需要获取其Segment的锁,所以Segment的锁只保护其HashEntry[]。例如要进行读写操作时,会根据key的hash来指派Segment,这时只需要获取这个Segment的锁,访问其他的Segment不会被阻塞。进入Segment之后安装key的hash指派到HashEntry即可。

Fork/Join框架

RecursiveAction用于没有返回结果的任务

RecursiveTask用于有放回结果的任务

ForkJoinPoolForkJoinTask需要通过ForkJoinPool来执行

Fork/Join框架是通过递归的方法用来把单个大计算量的任务拆分成若干个小任务以便于并发计算的框架。通过继承RecursiveAction或者RecursiveTask,实现其compute方法。在compute方法里先判断任务是否足够小,是就计算返回结果,否则把任务拆分成若干个,再创建对应个数的同类对象来执行,等待同类对象返回计算结果后,把计算结果汇总返回。

CountDownLatch

CountDownLatch用来让一个或者线程等待其他线程完成操作。在CountDownLatch的构造方法指定等待数,等待线程调用await方法阻塞等待,操作线程完成操作后调用其CountDown方法表示完成一个,等待数减一,知道等待数为零,等待线程才恢复。

CyclicBarrier

CyclicBarrier用来阻塞一组线程在同步点,直到全部线程都到达同步点,才释放阻塞。CyclicBarrier的构造方法指定同步点个数,需要同步的方法到达同步点调用await方法挂起,同步点数减一,直到同步点数为零,全部阻塞的线程才会恢复。

Semaphore

Semaphore用来控制活动线程数。Semaphore构造函数指定活动线程数,某个线程调用其acquire方法,如果活动线程数未满,则直接返回,否则被阻塞直到有线程退出。线程退出需调用其release方法。

线程池不说了,本书完结。

参考文献

Java中的锁

java 中的锁 -- 偏向锁、轻量级锁、自旋锁、重量级锁


java并发编程的艺术学习笔记
https://cellargalaxy.github.io/posts/java/4.java并发编程的艺术学习笔记/
作者
cellargalaxy
发布于
2018年2月4日
许可协议