其实对于并发编程来说,无论是什么语言,原理上都是大同小异的。本文结合对java并发编程的研究。从头梳理一下并发编程的林林种种(内容主要参考 java并发编程艺术一书)
多线程并不一定比单线程快
大家都听过龟兔赛跑,乌龟和兔子赛跑的过程中,兔子跑了一段以后开始睡觉了。但是乌龟确一直坚持往下跑最终比兔子先到达终点。本文对于多线程的回顾目的也就是要深扒一下“为什么理论上的快,结果确不一定快”。这里主要探讨一下几点。上线文切换,死锁,各种硬件的限制等。
上下文切换
提起并发编程,其设计初衷来说也就是想让程序运行的更快,线程也就是并发编程里非常重要的概念。
有同学可能有误区,认为单核的cpu不存在多线程。其实不然,线程是进程的单元。即使是单核cpu也存在多线程的概念。那么多线程具体是如果实现的呢?这里不得不说一个概念
上下文切换
cpu 给每个线程分配一定的时间片,这里的时间片比较短具体有多短我们可以在查询相关资料,cpu通过不停的切换线程执行,让我们根本感受不到是否是单个cpu在运行还是多个cpu在同时运行,由于切换线程执行需要保存当前线程的执行状态,以方便下次重新运行到间歇的线程的时候可以从其休息的地方继续运行。因此这一整个过程被称为上下文切换。
通过概念的理解我们可以看到,上下文切换是有时间消耗的,随着线程的增多和任务量的增加。上下文切换的消耗也明显增大
减少上下文切换
无锁并发编程,CAS算法,使用最少线程,协程
无锁并发编程
上面讲到了上下文切换,在多线程环境中使用锁也就是主动的创造了上下文切换的条件。如书上说到常见的有 通过对整个数据进行Hash取模分段,然后每个线程单独处理一段数据内容互相之间没有关联
CAS算法
参考Atomic包里面的常用类,无需要加锁
使用最少线程
对于不需要创建多线程的场景,尽量用做少的线程做更多的事,减少线程等待
协程
协程的概念是单线程里维持多任务的调度,并在单线程里维护多任务的切换,后续我们参考golang的channel等在具体做说明
死锁
下面这是个常见测试死锁的案例,同时启动两个线程,线程内两个对象 b 和 a 分别被线程threadA 和threadB持有,两个线程互相等待对方释放锁资源。最终形成了死锁。这里只是简单介绍可能引起死锁的原因。具体工程里可能代码要复杂一些。
public class TestDeadThread {
static String a = "a";
static String b = "b";
public static void main(String args[]) {
new Thread("threadA") {
@Override
public void run() {
synchronized (b) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (a) {
System.out.print("do some thing a");
}
}
}
}.start();
new Thread("threadB") {
@Override
public void run() {
synchronized (a) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b) {
System.out.print("do some thing b");
}
}
}
}.start();
}
}
这里我们用VisualVm工具来查看dump 查看死锁发生的情况
同时我们执行dump查看具体发生死锁的原因,下面的提示比较清楚
java并发机制原理
书中提到java代码被编译为字节码,并有jvm虚拟机加载,执行最终编译为机器能认识的汇编指令到cpu上执行,通过java代码的执行流程我们可以看到,并发编程离不开jvm实现和cpu指令。所以我们探讨并发机制的时候 也离不开对这两点的探讨
volatile原理
提到volatile
关键字,我们想到的是 可见性,和不需要上下文切换,比synchronized要轻,我们知道每个线程都拥有其独立的jvm内存空间,但是使用volatile修饰的变量,java内存模型确保其被所有的线程看到的值是一致的。具体在cpu层面如何实现的呢,相信一直是困扰程序员的问题
下面我们通过一个案例来分析volatile的实现,我们用volatile 申明了一个data变量,当一个线程发生些操作的时候,如下图所示,如果我们申明了一个变量为volatile,那么在汇编指令里会生成一条Lock的命令。其主要做下面两件事
- 将当前处理器的缓存行内容重新写回内存
- 该写操作会使其他处理器的缓存失效
由于cpu这块理论比较不好理解,这里就不说太多书上cpu的概念性的东西,我们记住cpu为了提高效率会存在内部缓存,当我们给变量申明为volatile的时候,cpu执行写操作前都会执行Lock指令,如上说明了Lock指令做的任务,同时因为每个处理器使用嗅探在总线上传播的数据来检查是否自己的缓存已失效,如果发现自己保存的缓存已失效,并且当自己对这个无效数据进行操作的时候 就会重新从内存中把数据在读到处理器缓存中
(有兴趣的同学可以在去深入了解一下缓存命中
,缓存行填充
,内存屏障
等原理)
synchronized原理
java的每个对象都是可以作为锁,对于普通同步方法,锁是实例对象,对于静态同步方法,锁是当前类的Class的对象,对于同步方法块,锁是Synchonized括号里配置的对象,当一个线程视图访问同步代码块时,首先得到锁,退出或抛出异常的时候释放锁
代码块同步是通过monitor
来实现的,也就是在代码编译后monitorenter
指令被插入到同步代码块开始的地方,monitorexit
被插入到方法结束和异常地方。Jvm保证了其成对出现,每一个对象都会有一个monitor
关联。当一个monitor被持有,就处于了锁定状态,线程执行到monitorenter指令时会尝试获取对象对应的monitor所有权。即尝试获取对象的锁
。
方法的同步区别于代码块的同步,暂时未知具体实现方式,也可以使用如上指令来实现
synchronized的锁存在对象头里。简单了解一下对象头
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 对象的hashCode或锁信息 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/64bit | Array length | 数组的长度(如果是数组的话) |
下面这个概念比较重要,我们不要以为加锁了就一定存在等待或者互斥等,这样的设计就太差了,java在经过这么多伦的演变,设计者也一直在考虑不同场景下能实现下效率最大化,如对资源竞争不激烈到竞争激烈的情况下就不能使用同样的重量级锁的方案。
从java se 1.6以后为了减少获得锁和释放锁带来的性能消耗,引入了’偏向锁’,‘轻量级锁’,而且锁应该有四种状态从低到高依次是:无锁状态,偏向锁状态,轻量级锁状态,重量级锁状态。这些状态会随着竞争逐渐升级,但是不可以降级。这样做主要为了增加获得锁和释放锁的效率。
偏向锁
主要解决同一个锁总是有同一个线程多次获得和释放。这样虽然只有一个线程但是每次也要多次处理加锁和释放锁的操作。偏向锁的处理方案是,当一个线程访问同步块并获得锁的时候,对象头和栈帧里存储锁偏向的线程id。偏向锁是等到竞争出现才会释放锁的一种设计
-
无竞争条件下
线程A进入和退出同步块不需要进行CAS操作(加锁和释放),只用测试对象头MarkWord里是否存储指向单钱线程的偏向锁,如果有则线程获得锁,如果没有在测试一下MarkWord偏向锁标识是否是1(1为偏向锁状态)如果是,尝试使用CAS把对象头的偏向锁执行当前线程,如果当前不是偏向锁交给CAS出现竞争 -
有竞争的条件下
线程B尝试进入同步块,检查对象头中的是否存储了线程2如果没有的话CAS尝试替换MarkWord,如果替换成功则执行同步代码,当前偏向锁就偏向了B,如果替换不成功的话,说明当前偏向锁指向其他线程,需要暂停其他线程,解锁偏向锁,将线程id为空,此时线程处于无锁状态,然后恢复其他线程执行。
可以设置-XX:-UseBiasedLocking = false 默认程序直接进入轻量级锁
轻量级锁
-
加锁
线程执行到同步块之前,JVM会嫌在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,然后线程尝试使用CAS将对象头中的MarkDown替换为执行锁记录的指针,如果这些操作成功 当前线程获得锁,否则表示存在其他线程竞争,当前线程尝试使用自旋
(指的是不同的通过cpu调度)来获取锁 -
解锁
当轻量级锁解锁时,使用原子的CAS 将栈中写入的MarkWord 替换会对象头,如果成功,表示没有竞争,否则的话表示当前锁存在竞争,锁自动膨胀成重量级锁。
重量级锁
重量级锁就没什么特别说的了,就是每次线程执行到同步块的时候,都要检查是否同步块被其他线程占优锁,如果没有则执行有的话则等待。
锁 | 优点 | 缺点 | 场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外消耗 | 如果线程存在锁竞争会因为撤销锁带来额外消耗 | 适用于只有一个线程方位同步块 |
轻量级锁 | 竞争的线程不会阻塞 | 如果始终得不到锁竞争的线程就自旋消耗cpu性能 | 追求响应时间 |
重量级锁 | 线程竞争不会自旋 | 线程阻塞相应慢 | 追求吞吐量 |
参考代码HotSpot 对象头 ‘markPop.hpp’,偏向锁 biasedLocking.cpp
以及ObjectMonitor.cpp
, BasicLock.cpp
基于上面的理论我们把并发编程,锁,原子操作等基础理论复习了一下。后续我们就通过java常用的并发编程api来具体在探讨一下这些理论的应用
原子操作原理
cpu层面实现原理
原子性是我们在并发编程里面也是常常被提到的点。原子操作是指的是不可被终端的一个或者一系列的操作,尤其是当多处理器的时候 原子操作变的更加复杂。下图的cpu的一些概念仅做参考
具体引发非原子性的操作就不做详细介绍了,因为所有处理器共享内存,因此对于同一块内存地址的操作如果不加约束的话,很容易出现非原子性的操作
处理器控制原子性的方案大体如下
-
使用总线锁保证原子性
,所谓的总线锁就是处理器 提供了一个Lock#的指令,当一个cpu在总线上输出信号,其他处理器的请求将被阻塞,这样处理器就独占了内存 -
缓存锁定
指的是频繁使用的内存会被缓存在cpu的高速缓存L1L2L3里,当使用缓存锁定的时候,cpu不会发送Lock#指令,而是cpu的缓存有默认的缓存一致性,当其他处理器会写已被锁定的缓存行的数据时,会是缓存行无效。(比如当处理器1修改了缓存行中x的数据时使用了缓存锁定,处理器2就不能在修改缓存x的缓存行)
有两种情况不能使用缓存锁定- 操作数据不能被缓存在处理器内部,或者操作的数据跨了多个缓存行
- 有些处理器不支持缓存锁定
java实现
Java中可以通过锁和循环CAS的方式来实现原子操作。这里的CAS 也就是上面说的LOCK# CMPXCHG指令来实现。自旋CAS就是循环进行CAS操作指导成功为止。java的这种原子操作存在三个问题 ABA问题
(变量由A变成B又变成A),循环时间长开销大
,只能保证一个变量原子操作
当然java版本的更替 这三个问题也相应的都有了解决方案
ABA问题
解决思路是在变量前加版号,如变量修改时1A-2B-3A AtomicStampedRefence类就是来解决ABA的问题,这个类提供了一个compareAndSet方法,检查当前引用和标志是否等于预期,如果全部相等才用原子操作赋值最新的值
循环时间长开销大
jvm如果支持pause指令会降低循环开销。
pause
指令 延迟流水线执行指令,使cpu不会消耗太多资源,同时避免自旋退出的时候因为内存顺序冲突导致CPU流水线被情况,大概理解即可
只能保证一个变量原子操作
,AtomicReference可以把多个变量放在一个对象里进行CAS操作
锁机制,如上面说的提供了偏向锁,轻量级锁和重量级锁来避免通知操作一个内存区域。JVM的锁机制也是循环CAS,(进入同步块的时候循环CAS获取锁,退出的时候循环CAS释放锁)
–未完待续 –