JVM学习笔记(四)——Java内存模型和线程安全

Java内存模型和线程安全

由于计算机的运算能力十分强大而且和计算机的存储和通信子系统的的速度差距过大,大量时间都花费在I/O、网络通信和数据库访问上,因此让计算机同时处理几项任务是最容易而且也更有效的“压榨”计算机运算能力的手段。

处理器至少要与内存交互,如读取数据、存储运算结果等。这个I/O操作是几乎无法消除的。而且由于计算机存储设备和处理器运算速度上相差好几个数量级,因此我们在内存和处理器之间加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存中同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

一、硬件效率一致性

缓存一致性问题:在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,处理器、高速缓存、主内存之间的交互关系如下图所示:

处理器内存缓存交互关系

除增加高速缓存以外,处理器还可能会对输入指令进行乱序执行优化(**指令重排优化**),虽然保证该结果与顺序执行结果一样,但不保证各个语句的执行先后顺序和输入代码中的顺序一致。






二、Java内存模型

2.1 主内存和工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

此处的变量是指实例字段、静态字段和构成数组对象的元素,但不包括局部变量方法参数,因为这些是线程私有的,不会被共享,自然也不会被竞争了

Java内存模型规定所有的变量都存储在主内存,每条线程还有自己的工作内存,其中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能够直接读取主内存的变量,不同的线程无法直接访问对方工作内存中的变量,线程间变量值的传递需通过主内存来完成。

Java内存模型



2.2 内存间交互操作

关于主内存和工作内存之间具体的交互协议,即一个变量如何从主内存拷贝考到工作内存、如何从工作内存同步回主内存之间具体的实现细节,Java内存模型定义了以下8中操作来完成,Java虚拟机必须保证下面提及的每一种操作都是原子的(对于long和double类型来说,load,store,read和write在某些平台上允许有例外)

操作 作用对象 解释
lock(锁定) 主内存 把一个变量标识为一条线程独占的状态
unlock(解锁) 主内存 把一个处于锁定状态的变量释放出来,释放后才可被其他线程锁定
read(读取) 主内存 把一个变量的值从主内存传输到线程工作内存中,以便 load 操作使用
load(载入) 工作内存 把 read 操作从主内存中得到的变量值放入工作内存中
use(使用) 工作内存 把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量值的字节码指令时将会执行这个操作
assign(赋值) 工作内存 把一个从执行引擎接收到的值赋接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
store(存储) 工作内存 把工作内存中的一个变量的值传送到主内存中,以便 write 操作
write(写入) 工作内存 把 store 操作从工作内存中得到的变量的值放入主内存的变量中

如果要把一个变量从主内存赋值到工作内存,就要顺序地执行read(读取)和load(加载)操作
如果要把一个变量从工作内存同步回主内存,就要顺序地执行store(存储)和write(写入)操作

要求以上规则必须按照顺序执行,而没有保证是连续执行

Java内存模型规定了执行上述8种规则时,必须要求满足以下规则:

  • 不允许read和load、store和write操作之一单独出现,也就是不允许从主内存读取了变量的值但是工作内存不接收的情况,或者不允许从工作内存将变量的值回写到主内存但是主内存不接收的情况出现
  • 不允许一个线程丢弃最近的assign操作,也就是不允许线程在自己的工作线程中修改了变量的值却不同步/回写到主内存
  • 不允许一个线程无缘由的(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中
  • 一个变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化的变量(load或者assign操作)。换句话说,在执行use、store之前必须对相同的变量执行了load、assign操作
  • 一个变量在同一时刻只允许被一个线程对其进行lock操作,但lock操作可以被同一条线程执行重复执行多次,相应地,执行多次后,只有执行相同次数的unlock才会被解锁
  • 对变量执行lock操作,将会清空工作空间该变量的值,执行引擎使用这个变量之前,需要重新load或者assign操作初始化变量的值
  • 不允许对没有被lock的变量执行unlock操作,如果一个变量没有被lock操作,那也不能对其执行unlock操作,当然一个线程也不能对被其他线程lock的变量执行unlock操作
  • 对一个变量执行unlock之前,必须先把变量同步回主内存中(执行store和write操作)

Java内存模型对于上述8个操作都具有原子性,但对于64位数据类型long和double,在模型中特别定义了一条相对宽松的定义:允许将没有volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即不保证它们的原子性




2.3 volatile修饰的变量的特殊规则

关键字volatile可以说是java虚拟机中提供的最轻量级的同步机制,当一个变量被定义为volatile之后,它将具备两种特性:

  • 保证此变量对所有线程的可见性

    可见性是指当一个线程修改了这个变量的值,新值(修改后的值)对于其他线程来说是立即可以得知的,普通变量需要通过主内存来同步实现。当修改volatile变量时,会给cpu发送一个信号告诉其他cpu这个变量已修改,当其他cpu调用这个变量时,就会先检查是否有收到修改该变量的信号,有则重新从内存中读取。volatile是无锁的,类似于乐观锁的机制。 简单来说,就是volatile变量进行读时,会有一个主内存到工作内存到拷贝动作进行写后,会有一个工作内存刷新主内存到动作

    虽然volatile变量对所有线程是立即可见的,所以对volatile变量的所有修改(写操作)都立刻能反应到其他线程中,换句话说:volatile变量在各个线程中是一致的,但是并不能得出基于volatile变量的运算在并发下是线程安全的。 因为每次使用之前都需要先刷新,所以不存在不一致的情况,但是Java里面的操作并非原子性,即使编译出来只有一条字节码指令,但是也并不意味着这条指令就是一个原子操作。

    由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景下,我们仍然需要对其加锁(通过synchronized或java.util.concurrent中的原子类)来保证原子性

    • 运算结果并不依赖当前值,或能够确保只有单一线程来修改变量的值
    • 变量不需要与其他的状态变量共同参与不变约束

    可见性通过synchronized和final也可以实现, synchronized是要求对一个变量执行unl之前,必须同步回主内存,final是指被它修饰的变量一旦在构造器中完成,并且构造器没有吧“this”抛出去,那么在其他线程就能够看见final字段的值

  • 禁止指令重排序

    普通的变量仅仅会保证在该方法执行的过程中,所有依赖赋值结果的地方都能获取到正确的结果,但不能保证变量赋值的操作顺序和程序代码的顺序一致。详见 双检锁/双重校验锁(增加volatile) 从硬件架构上讲,指令重排序是指cpu采用了允许将多条指令不按程序规的顺序分开送给各相应的电路单元处理.

在某些情况下,volatile的同步机制性能要优于锁(使用synchronized关键字或者java.util.concurrent包中的锁)。但是现在由于虚拟机对锁的不断优化和实行的许多消除动作,很难有一个量化的比较。
volatile变量的读操作和普通变量的读操作几乎没有差异,但是写操作会性能差一些,慢一些,因为要在本地代码中插入许多内存屏障指令来禁止指令重排序,保证处理器不发生代码乱序执行行为。不过即便如此,大多数情境下volatile的开销还是要比锁要低一些,与其选择的依据是volatile的语义事发后能够满足使用场景的需求。



2.4 先行先发生规则

也就是 happens-before 原则。这个原则是判断数据是否存在竞争、线程是否安全的主要依据。先行发生是 Java 内存模型中定义的两项操作之间的偏序关系。

规则 解释
程序次序规则 在一个线程内,代码按照书写的控制流顺序执行
管程锁定规则 一个 unlock 操作先行发生于后面对同一个锁的 lock 操作
volatile 变量规则 volatile 变量的写操作先行发生于后面对这个变量的读操作
线程启动规则 Thread 对象的 start() 方法先行发生于此线程的每一个动作
线程终止规则 线程中所有的操作都先行发生于对此线程的终止检测(通过 Thread.join() 方法结束、 Thread.isAlive() 的返回值检测)
线程中断规则 对线程 interrupt() 方法调用优先发生于被中断线程的代码检测到中断事件的发生(通过 Thread.interrupted() 方法检测)
对象终结规则 一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始
传递性 如果操作 A 先于 操作 B 发生,操作 B 先于 操作 C 发生,那么操作 A 先于 操作 C






三、Java与线程

3.1 使用内核线程实现

直接由操作系统内核支持的线程,这种线程由内核完成切换。程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口 —— 轻量级进程(LWP),轻量级进程就是我们通常意义上所讲的线程,每个轻量级进程都有一个内核级线程支持。

内核线程实现

系统调用的代价较高,需要在用户态和内核态中来回切换

其次,每一个轻量级进程都需要一个内核线程的支持,因此轻量级进程要消耗一定内核资源,因此一个系统支持轻量级进程的数量是有限的

例如synchronized锁的实现就是如此,所以它是一个重量级操作(当然虚拟机会进行一些优化)



3.2 使用用户线程实现

广义上来说,只要不是内核线程就可以认为是用户线程,因此可以认为轻量级进程也属于用户线程。狭义上说是完全建立在用户空间的线程库上的并且内核系统不可感知的实现。用户进程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。

用户线程实现

优势是操作非常快速并且低消耗的劣势在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。线程的创建、切换和调度都是需要考虑的问题,因而使用用户线程的程序实现一般都比较复杂



3.3 使用用户线程夹加轻量级进程混合实现

混合实现



3.4 Java线程的实现

平台不同实现方式不同,可以认为是一条 Java 线程映射到一条轻量级进程。



3.5 Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要有两种调度方式:

  • 协同式线程调度

    线程执行时间由线程自身控制,实现简单,切换线程自己可知,所以基本没有线程同步问题。坏处是执行时间不可控,容易阻塞

  • 抢占式线程调度

    每个线程由系统来分配执行时间

虽然Java线程调度是系统自己完成的,但是我们也可以通过优先级(Java语言共用10个优先级)让某些线程优先完成,获得更多的执行时间。

不过线程优先级并不是特别可靠,原因是Java的线程是通过映射到系统的原生的线程上来实现的以及优先级可能会被系统所改变,所以线程调度最终还是得取决于操作系统。

3.6 线程状态转换

Java语言一共定义了5种线程状态,在任意一个时间点,一个线程有且只在其中一种状态:

  1. 新建(new)

    创建后尚未启动的线程。

  2. 运行(Runable)

    Runable 包括了操作系统线程状态中的 Running 和 Ready,也就是出于此状态的线程有可能正在执行,也有可能正在等待 CPU 为他分配时间。

  3. 无限期等待(Waiting)

    出于这种状态的线程不会被 CPU 分配时间,它们要等待被其他线程显示地唤醒,以下方法会然线程进入无限期等待状态:

    • 没有设置 Timeout 参数的 Object.wait() 方法。
    • 没有设置 Timeout 参数的 Thread.join() 方法。
    • LookSupport.park() 方法。
  4. 限期等待(Timed Waiting)

    处于这种状态的线程也不会分配时间,不过无需等待被其他线程显示地唤醒,在一定时间后他们会由系统自动唤醒。以下方法会让线程进入限期等待状态:

    • Thread.sleep() 方法。
    • 设置了 Timeout 参数的 Object.wait() 方法。
    • 设置了 Timeout 参数的 Thread.join() 方法。
    • LockSupport.parkNanos() 方法。
    • LockSupport.parkUntil() 方法。
  5. 阻塞(Blocked)

    线程被阻塞了,“阻塞状态”和“等待状态”的区别是:“阻塞状态”在等待着获取一个排他锁,这个时间将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。

  6. 结束(Terminated)

    已终止线程的线程状态,线程已经结束执行






四、线程安全和锁优化

4.1 线程安全

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。



4.2 线程安全的等级

4.2.1 不可变

一定是线程安全的,例如被final修饰的变量或者String类型的变量

4.2.2 绝对线程安全

“不管运行环境如何,调用者都不需要任何额外的同步措施”,通常需要付出的代价很大,甚至有时候是不切实际的代价

4.2.3 相对线程安全

即我们通常意义上讲的线程安全,在调用时不需要做额外的保障措施,对于一些特定顺序的连续调用, 就可能在调用端使用额外的同步手段来保证调用的正确性。

4.2.4 线程兼容

线程兼容是指对象本身不是线程安全的,但是可以通过在调用端使用同步手段来保证对象在并发环境中可以安全地使用

4.2.5 线程对立

无论是否采用了同步措施,都无法在多线程环境中并发使用的代码



4.3 线程安全的实现方法

4.3.1 互斥同步

同步:在多个线程访问共享数据时,保证共享数据在同一时刻只被一个线程使用
互斥;是实现同步的一种手段,临界区、互斥量、信号量都是主要的互斥实现手段,

互斥是方法,同步是目的(互斥的本质也是同步)

  1. 最基本的同步互斥手段是synchronized关键字

它在经过编译后,会在同步块的前后分别形成monitorenter和monitoreif这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。
在执行monitorenter时,首先要尝试去获取对象的锁,如果这个对象没有被锁定,或者当前线程已经拥有此对象的锁时,则将其加1,相应的执行monitoreif时,会将锁减1,当计数器为0时,锁就会被释放。(有一些类似信号量,但是这个在对于一个线程内,因为一个线程可能掉用多次)

synchronized对于同一条线程来说是可重入的,不会出现自己锁死自己的问题。其次,同步块在已进入的线程执行完之前,会阻塞后面的线程进入

  1. 重入锁ReetrantLock

和synchronized一样,都具有线程可重入性

可重入性:若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。一般而言,可重入的函数一定是线程安全的,反之则不一定成立。

相比synchronized,增加了一些高级功能:

  • 等待可中断。 当持有锁的线程长期不释放锁时,正在等待的线程可以放弃等待去做一些其他事情。
  • 可实现公平锁。 ReetrantLock锁默认是非公平锁,但是可以通过带布尔值的构造函数来实现公平锁。
  • 锁绑定多个条件。 一个ReetrantLock对象可以同时绑定多个条件。

在JDK1.6之后,由于虚拟机对锁实现了很多优化,因此,这两者性能差不多,而且在未来的性能改进中,也会更加偏向synchronized,所以优先选择synchronized

4.3.2 非阻塞同步

互斥与同步属于一种悲观锁,总是认为如果不去做正确的同步措施,就肯定会出现错误,无论数据是否会出现竞争都会对其进行加锁。因此由于进行线程阻塞和换醒会带来一些性能问题。

随着指令集的发展我们有了另外一种选择:基于冲突检测的乐观并发策略。即先进行操作,如果共享数据出现争用,产生了冲突,则采取其他的补偿措施(最常见的就是不断重复,直至成功)

4.3.3 无同步方案

要保证线程安全,并不一定要进行同步,两者没有因果关系。(如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性)



4.4 锁优化

4.4.1 自旋锁和自适应锁

4.4.2 锁消除

即时编译器在运行时,对一些代码上要求同步,但检测到不可能存在共享数据竞争的锁进行消除
锁消除的判定依据主要来源于逃逸分析的数据支持

4.4.3 锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁和解锁都是出现在循环体中的,那么即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。所以会将加锁同步的范围粗化到整个操作序列的外部

4.4.4 轻量级锁

无竞争的情况下使用CAS操作去除同步使用的互斥量

4.4.5 偏向锁

在无竞争的情况下把整个同步都消除掉。即偏向于第一个获得的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不会需要再进行同步

偏向锁可以提高有同步但无竞争的程序性能,但并不一定总是对程序有利。如果程序中大多数的锁总是被多个不同的线程访问,那么偏向锁就是多余的。








参考

[1]周志明.深入理解Java虚拟机.北京:机械工业出版社
[2] https://blog.csdn.net/qq_41701956/article/details/81664921

猜你喜欢

转载自www.cnblogs.com/ccink/p/13375113.html