【Java 线程系列】一文看懂--并发编程归纳总结
@TOC
一、JMM 基础-计算机原理
Java 内存模型即 Java Memory Model,简称JMM。JMM 定义了Java 虚拟机 (JVM)在计算机内存(RAM)中的工作方式。JVM 是整个计算机虚拟模型,所以 JMM 是隶属于 JVM 的。Java1.5 版本对其进行了重构,现在的 Java 仍沿用了 Java1.5 的版本。Jmm 遇到的问题与现代计算机中遇到的问题是差不多的。
物理计算机中的并发问题,物理机遇到的并发问题与虚拟机中的情况有不少 相似之处,物理机对并发的处理方案对于虚拟机的实现也有相当大的参考意义。
根据《Jeff Dean 在 Google 全体工程大会的报告》我们可以看到
计算机在做一些我们平时的基本操作时,需要的响应时间是不一样的。
以下案例仅做说明,并不代表真实情况。
如果从内存中读取 1M 的 int 型数据由 CPU 进行累加,耗时要多久?
做个简单的计算,1M 的数据,Java 里 int 型为 32 位,4 个字节,共有 1024*1024/4 = 262144 个整数 ,则 CPU 计算耗时:262144 0.6 = 157286 纳秒, 而我们知道从内存读取 1M 数据需要 250000 纳秒,两者虽然有差距(当然这个差距并不小,十万纳秒的时间足够 CPU 执行将近二十万条指令了),但是还在 一个数量级上。但是,没有任何缓存机制的情况下,意味着每个数都需要从内存 中读取,这样加上 CPU 读取一次内存需要 100 纳秒,262144 个整数从内存读取 到 CPU 加上计算时间一共需要 262144100+250000 = 26 464 400 纳秒,这就存在 着数量级上的差异了。
而且现实情况中绝大多数的运算任务都不可能只靠处理器“计算”就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个 I/O 操作是基本上是无法消除的(无法仅靠寄存器来完成所有运算任务)。早期计算机中 cpu 和内存的速度是差不多的,但在现代计算机中,cpu 的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所 以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样 处理器就无须等待缓慢的内存读写了。
在计算机系统中,寄存器是 L0 级缓存,接着依次是 L1,L2,L3(接下来是内存,本地磁盘,远程存储)。越往上的缓存存储空间越小,速度越快,成本也更高;越往下的存储空间越大,速度更慢,成本也更低。从上至下,每一层都可以看做是更下一层的缓存,即:L0 寄存器是 L1 一级缓存的缓存,L1 是 L2 的缓存,依次类推;每一层的数据都是来至它的下一层,所以每一层的数据是下一 层的数据的子集。
在现代 CPU 上,一般来说 L0, L1,L2,L3 都集成在 CPU 内部,而 L1 还分 为一级数据缓存(Data Cache,D-Cache,L1d)和一级指令缓存(Instruction Cache, I-Cache,L1i),分别用于存放数据和执行数据的指令解码。每个核心拥有独立 的运算处理单元、控制器、寄存器、L1、L2 缓存,然后一个 CPU 的多个核心共 享最后一层 CPU 缓存 L3。
二、Java 内存模型(JMM)
从抽象的角度来看,JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
2.1、可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值, 其他线程能够立即看得到修改的值。
由于线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,那么对于共享变量 V,它们首先是在自己的工作内存,之后再同步到主内存。可是并不会及时的刷到主存中,而是会有一定时间差。很明显,这个时候线程 A 对变量 V 的操作对于线程 B 而言就不具备可见性了 。
要解决共享对象可见性这个问题,我们可以使用 volatile 关键字或者是加锁。
2.2、原子性
原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
我们都知道 CPU 资源的分配都是以线程为单位的,并且是分时调用,操作系统允许某个进程执行一小段时间,例如 50 毫秒,过了 50 毫秒操作系统就会重新选择一个进程来执行(我们称为“任务切换”),这个 50 毫秒称为“时间片”。 而任务的切换大多数是在时间片段结束以后,。
那么线程切换为什么会带来 bug 呢?
因为操作系统做任务切换,可以发生在任何一条 CPU 指令执行完!注意,是 CPU 指令,CPU 指令,CPU 指令,而不是高级语言里的一条语句。比如 count++,在 java 里就是一句话,但高级语言里一条语句往往需要多条 CPU 指令完成。其实 count++至少包含了三个 CPU 指令!
三、volatile 详解
3.1、volatile 特性
可以把对 volatile 变量的单个读/写
,看成是使用同一个锁对这些单个读/写
操作做了同步
public class Volati {
// 使用volatile 声明一个64位的long型变量
volatile long i = 0L;
// 单个volatile 变量的读
public long getI() {
return i;
}
// 单个volatile 变量的写
public void setI(long i) {
this.i = i;
}
// 复合(多个)volatile 变量的 读/写
public void iCount(){
i ++;
}
}
可以看成是下面的代码:
public class VolaLikeSyn {
// 使用 long 型变量
long i = 0L;
public synchronized long getI() {
return i;
}
// 对单个的普通变量的读用同一个锁同步
public synchronized void setI(long i) {
this.i = i;
}
// 普通方法调用
public void iCount(){
long temp = getI(); // 调用已同步的读方法
temp = temp + 1L; // 普通写操作
setI(temp); // 调用已同步的写方法
}
}
所以 volatile 变量自身具有下列特性:
- 可见性:对一个 volatile 变量的读,总是能看到(任意线程)对这个 volatile 变量最后的写入。
- 原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。
volatile 虽然能保证执行完及时把变量刷到主内存中,但对于 count++这种非原子性、多指令的情况,由于线程切换,线程 A 刚把 count=0 加载到工作内存, 线程 B 就可以开始工作了,这样就会导致线程 A 和 B 执行完的结果都是 1,都写到主内存中,主内存的值还是 1 不是 2
3.2、volatile 的实现原理
- volatile 关键字修饰的变量会存在一个“lock:”的前缀。
- Lock 前缀,Lock 不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock 会对 CPU 总线和高速缓存加锁,可以理解为 CPU 指令级的一种锁。
- 同时该指令会将当前处理器缓存行的数据直接写会到系统内存中,且这个写 回内存的操作会使在其他 CPU 里缓存了该地址的数据无效。
四、synchronized 的实现原理
Synchronized 在 JVM 里的实现都是基于进入和退出 Monitor 对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的 MonitorEnter 和 MonitorExit 指令来实现。
对同步块,MonitorEnter 指令插入在同步代码块的开始位置,而 monitorExit 指令则插入在方法结束处和异常处,JVM 保证每个 MonitorEnter 必须有对应的 MonitorExit。总的来说,当代码执行到该指令时,将会尝试获取该对象 Monitor 的所有权,即尝试获得该对象的锁:
- 如果 monitor 的进入数为 0,则该线程进入 monitor,然后将进入数设置为 1,该线程即为 monitor 的所有者。
- 如果线程已经占有该 monitor,只是重新进入,则进入 monitor 的进入数加 1。
- 如果其他线程已经占用了 monitor,则该线程进入阻塞状态,直到 monitor 的进入数为 0,再重新尝试获取 monitor 的所有权。 对同步方法,从同步方法反编译的结果来看,方法的同步并没有通过指令 monitorenter 和 monitorexit 来实现,相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。
JVM 就是根据该标示符来实现方法的同步的:当方法被调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任何线程都无法再获得同一个 monitor 对象。
synchronized 使用的锁是存放在 Java 对象头里面,Java 对象的对象头由 mark word 和 klass pointer 两部分组成:
- mark word 存储了同步状态、标识、hashcode、GC 状态等等。
- klass pointer 存储对象的类型指针,该指针指向它的类元数据 另外对于数组而言还会有一份记录数组长度的数据。
锁信息则是存在于对象的 mark word 中,MarkWord 里默认数据是存储对象的 HashCode 等信息。
但是会随着对象的运行改变而发生变化,不同的锁状态对应着不同的记录存储方式
4.1、锁的状态
对照上面的图中,我们发现锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态, 它会随着竞争情况逐渐升级。锁可以升级但不能降级,目的是为了提高获得锁和 释放锁的效率。
4.2、偏向锁
引入背景:大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的 CAS 操作。
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中, 同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,减少加锁/解锁的一些 CAS 操作(比如等待队列的一些 CAS 操作),这种情况下,就会给线程加一个偏向锁。 如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM 会消除它身上的偏向锁,将锁恢复到标 准的轻量级锁。它通过消除资源无竞争情况下的同步原语,进一步提高了程序的 运行性能。
看下面图,了解偏向锁获取过程:
步骤 1、 访问 Mark Word 中偏向锁的标识是否设置成 1,锁标志位是否为 01,确认为可偏向状态。
步骤 2、 如果为可偏向状态,则测试线程 ID 是否指向当前线程,如果是, 进入步骤 5,否则进入步骤 3。
步骤 3、 如果线程 ID 并未指向当前线程,则通过 CAS 操作竞争锁。如果竞 争成功,则将 Mark Word 中线程 ID 设置为当前线程 ID,然后执行 5;如果竞争 失败,执行 4。
步骤 4、 如果 CAS 获取偏向锁失败,则表示有竞争。当到达全局安全点 (safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致 stop the word)
步骤 5、 执行同步代码。
偏向锁的释放:
偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
偏向锁的适用场景:
始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致 stop the word 操作;
在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向锁的时候会导致进入安全点,安全点会导致 stw,导致性能下降,这种情况下应当禁用。
jvm 开启/关闭偏向锁
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 关闭偏向锁:-XX:-UseBiasedLocking
4.3、 轻量级锁
轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;
轻量级锁的加锁过程:
- 在代码进入同步块的时候,如果同步对象锁状态为无锁状态且不允许进行偏向(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,官方称之为 Displaced Mark Word。
- 拷贝对象头中的 Mark Word 复制到锁记录中。
- 拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock record 里的 owner 指针指向 object mark word。如果更新成功,则执行步骤 4,否则执行步骤 5。
- 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态
- 如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,那么它就会自旋等待锁,一定次数后仍未获得锁对象。重量级线程指针指向竞争线程,竞争线程也会阻塞,等待轻量级线程释放锁后唤醒他。锁标志的状态值变为“10”,Mark Word 中存储 的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
4.3.1、自旋锁原理
自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
但是线程自旋是需要消耗 CPU 的,说白了就是让 CPU 在做无用功,线程不能一直占用 CPU 自旋做无用功,所以需要设定一个自旋等待的最大时间。
如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。
4.3.2、自旋锁的优缺点
自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗。
但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 cpu 做无用 功,占着 茅坑 不 那啥,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要 cup 的线程又不能获取到 cpu,造成 cpu 的浪费。
4.3.3、自旋锁时间阈值
自旋锁的目的是为了占着 CPU 的资源不释放,等到获取到锁立即进行处理。 但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响整体系统的性能。因此自旋次数很重要。
JVM 对于自旋次数的选择,jdk1.5 默认为 10 次,在 1.6 引入了适应性自旋锁, 适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的 自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是 最佳的一个时间。
JDK1.6 中-XX:+UseSpinning 开启自旋锁; JDK1.7 后,去掉此参数,由 jvm 控 制;
4.3.4、不同锁的比较
总结
连续写了好几天的线程文章了,说实话,挺难写的,不过还好,现在总算写完了。我在考虑,要不要把线程相关的面试题整理一份,如果你需要,请留言告诉我。
- 点赞
- 收藏
- 关注作者
评论(0)