写点什么

JAVA 内存模型与线程

用户头像
颇风
关注
发布于: 2020 年 05 月 18 日

一、Java 内存模型

Java 内存模型的主要目标是定义程序中各个变量的访问规则,即 JVM 中将变量存储到内存中和从内存中取出变量这样的底层细节,变量包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量和方法参数,内存模型规定了所有的变量都存储在主内存,线程对变量的所有操作(取值、赋值)都必须在内存中完成。


内存模型定义了 8 种操作来完成工作内存和主内存之间的实现细节,而且这几种操作都是原子的、不可再分的(64 位的 double 和 long 类型除外)。


image.png


lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占的状态。

unlock(解锁):作用于主内存的变量,将一个锁定状态的变量释放出来,释放的变量可由其他线程锁定。

read(读取):作用于主内存的变量,把一个变量从主内存传输到线程的工作内存中,以便随后的 load 使用。

load(载入):作用于工作内存的变量,将 read 操作从主内存中读取的变量放入工作内存的变量副本中。

use(使用):作用于工作内存的变量,把工作内存中的值传递给执行引擎,每当 jvm 遇到一个需要使用变量的值得字节码指令就执行这个操作。

assign(赋值):作用于工作内存的变量,把执行引擎接收到的值赋给工作内存中的变量,每当 jvm 遇到一个需要给变量赋值的字节码指令就执行这个操作。

store(存储):作用于工作内存的变量,把工作内存中的值传输到主内存中,供之后的 write 操作使用。

write(写入):作用于主内存的变量,把 store 操作传入的变量的值放入主内存中的变量中。


主内存->工作内存 read->load

工作内存->主内存 store->write

上述操作必须按顺序执行,但是并不一定需要连续执行。

针对以上操作需满足以下几个规则:

(1)不允许 read,load,store 和 write 单独出现

(2)不允许一个线程丢弃最近的 assign 操作

(3)一个新变量只能在内存中诞生

(4)一个变量在同一个时刻只允许一个线程进行 lock 操作

(5)不允许一个线程没有 assign 操作将数据从工作内存同步回主内存

(6)如果线程对一个变量进行 lock 操作,将会把工作内存中的数据清空。,需要从新 load 或 assgin 变量的值。

(7)对一个变量进行 unlock 操作之前,必须将数据从工作内存同步回主内存(执行 store,write 操作)


二、对 volatile 变量的特殊规则

JVM 提供的最轻量级的同步机制,它具备 2 种特性:

(1)保证此变量对所有线程的可见性,但并不保证并发安全,JAVA 里面的运算并非原子操作,所以 volatile 修饰的变量在并发条件下不能保证线程安全。

底层原理:java 的++操作,编译后由 4 条字节码指令(多条机器码指令)构成:

a.getstatic

b.iconst_1

c.iadd

d.putstatic

在执行 iconst_1,iadd 指令时,可能其他线程已经修改了操作栈顶的值,导致 pustatic 执行时,把已经过期的数据(比实际数据小)同步回主内存。

演示代码:

public class VolatileTest {
private static volatile int count = 0;
public static void main(String[] args) {
VolatileTest volatileTest = new VolatileTest();
for (int i = 0; i < 10000; i++) {
new Thread(new Runnable() { @Override public void run() { count++; } }).start(); } while (Thread.activeCount() > 2) { Thread.yield(); } System.out.println("累加结果:" + count); }}
复制代码


image.png


字节码:


image.png


预期结果应该是 10000,但是实际结果是 9996,说明在累加过程中某些线程把过期的值同步回了主内存。

volatile 的使用场景:

a.运算结果不依赖变量当前计算的值或者能够确保只有单一线程能够修改线程的值

b.变量不需要与其他状态变量共同参与不变约束(只需要一个变量就可以控制并发)


(2)禁止指令重排序优化

有 volatile 修饰的变量,赋值后多执行了一个"lock"操作,这个操作相当于一个内存屏障(指令重排序时不能将后面的指令重排序到内存屏障之前的位置),该指令使得本 cpu 的 cache 写入内存,同时会引起其他 CPU 或者别的内核无效化其 Cache,相当于做了"store 和 write 操作",因而使得 volatile 修饰的变量的修改对其他 CPU 立即可见。


三、线程并发过程中的原子性、有序性和可见性

原子性:基本数据类型的访问读写是具备原子性的,原子性变量操作包括 read、load、assign、use、store 和 write.

(1)synchroniozed 关键字,底层由字节码指令 monitorenter 和 monitorexit 实现。

(2)lock 和 unlock 操作

可见性:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。


有序性:在本线程中观察,所有操作都是有序的;如果在一个线程中观察另外一个线程,所有操作都是无序的。


四、Java 与线程

1.线程的实现

(1)使用内核线程实现

直接由操作系统内核支持的线程,内核通过调度器对线程进行调度,将线程任务反映到各个处理器上。每个内核线程可以认为是内核的一个分身,因此支持多线程的内核称为多线程内核,但是程序一般不直接使用内核线程,而是使用高级接口:轻量级进程(LWP),即我们平时说的线程,每个轻量级进程由一个内核线程支持,所以只有先支持内核线程,才能有轻量级进程,内核线程与轻量级进程是 1:1 的关系。

(2)使用用户线程实现

广义上指不是内核线程的线程就是用户线程,因此可以哦认为轻量级进程也属于用户线程,但是轻量级进程的实现是建立在内核上的,许多操作需要系统调用,频繁切换内核态(用户态->核心态,核心态->用户态),效率受到限制;广义上的用户线程指的是完全建立在用户空间的线程库中,系统内核感知不到线程的存在,用户线程的建立、同步。调度及销毁在用户态中完成,不会进入内核态,进程与用户线程是 1:N 的关系。

(3)使用用户线程和轻量级进程实现

既存在用户线程也存在轻量级进程,用户线程还是完全建立在用户空间中,支持大规模的用户线程并发,操作系统提供的轻量级进程作为用户线程和内核线程之间交互的桥梁,使用内核提供的线程调度功能及处理器映射,进而用户线程的系统调用用过轻量级进程完成,大大降低了整个进程被阻塞的风险,所以在这种实现方式下,用户线程和轻量级进程为 N:M 的关系。


2.Java 线程调度

a.协同式线程调度

线程的执行时间由自己控制,线程把工作完成之后,要主动通知操作系统切换到另外一个线程,缺点是一个线程阻塞会导致整个进程阻塞,好处是实现简单。

b.抢占式线程调度

每个线程由系统来分配执行时间,JAVA 使用抢占式调度,通过设置线程优先级(1-10)可由设置指定线程分配更多执行时间,优先级高的线程会被操作系统优先执行,但是线程优先级并不可靠,因为 JAVA 线程是通过映射到系统的原生线程上来实现的,最终线程调度取决于操作系统。

3.状态切换

Java 语言定义了 5 种线程状态.


image.png


五、Java 线程安全与锁优化


1.不可变

在 JAVA 语言中,如果共享数据是基本数据类型,只要在定义时使用 final 关键字修饰就可以保证它不可变,如果是一个对象,就需要保证对象的行为不会对对象的状态产生影响。

2.线程安全的实现方法

下图为悲观锁和乐观锁的区别:


(1)互斥同步(悲观锁)

使用 synchronized 关键字,synchronized 经编译后,在同步块前后形成 monitorenter 和 monitorexit 字节码指令,这 2 个指令都需要 reference 类型的参数来指明要锁定和解锁的对象,如果指明了对象参数,那就是该对象的 reference,没有明确指定,则根据关键字修饰的是实例方法还是类方法,取对应的实例对象或 Class 对象作为锁对象。在 JVM 执行 monitorenter 时,首先尝试获取对象的锁,如果对象没有被锁定,或当前线程已锁定当前对象,则锁计数器加 1,synchronized 同步块对同一个线程是可重入的,因此 synchronized 是可重入锁。反之,执行 monitorexit 指令时锁计数器减 1,当计数值等于 0 时,释放当前对象的锁;如果尝试获取锁失败,当前线程阻塞等待,直到拥有锁的线程释放当前对象的锁为止。

除此以外,还可以使用并发工具包下的 ReentrantLock(可重入锁),与 synchronized 不同的是,ReentrantLock 是 API 层面的互斥锁,需要主动使用 lock()和 unlock()方法配合使用完成加锁解锁操作,而且 ReentrantLock 还具有等待可中断、可实现公平锁以及锁可以绑定多个条件 3 个特性;synchronized 则是原生语法层面的互斥锁。


(2)非阻塞同步(乐观锁)

互斥同步属于悲观并发策略,无论共享数据是否会发生竞争都会对对象加锁,如果对数据进行操作的过程很短,加锁代价会较大,降低了系统性能。所以这就引出下文要介绍的基于冲突检测的乐观并发策略:先进行数据操作,如果没有其他线程在竞争共享数据,那么久操作成功,如果检测到冲突,就不断重试,知道操作成功为止。通俗点举个例子:比如今天可能会下雨,但是我并不一开始就打伞出门,而是当要下雨的时候才打开雨伞,不下雨就不打伞。而实现这一策略的核心便是 CAS(Compare-And-Swap,比较并交换),在 jdk1.5 之后,开始支持 CAS 操作,封装在 Unsafe 类(应用程序是无法直接调用该类封装的方法的)里面,CAS 的核心是需要 3 个操作数,分别是内存位置(可以理解为变量的内存地址,用 V 表示)、旧的预期值(A)和新值(B),CAS 执行成功的前提是当且仅当 V 等于 A 时,CPU 才将 V 更新为 B,整个处理过程是原子操作。下文中将举例说明 CAS 操作是如何避免阻塞同步的:

public class MyAtomicIntegerTest {    private static final int THREADS_CONUT = 10;    private static AtomicInteger count = new AtomicInteger(0);
public static void increase() { count.getAndIncrement(); }
public static void main(String[] args) { Thread[] threads = new Thread[THREADS_CONUT]; for (int i = 0; i < THREADS_CONUT; i++) { threads[i] = new Thread(new Runnable() { @Override public void run() { for (int i = 0; i < 10000; i++) { increase(); } } }); threads[i].start(); }
while (Thread.activeCount() > 1) { Thread.yield(); } System.out.println(count); }
}
复制代码


结果如下所示,通过 10 个线程自增 10000 次,预期结果是 100000,实际结果与预期结果相符,但是 increase()方法又没有加同步块,那是如何做到的呢?我们继续探究。


image.png


                 

实现该操作的核心是 AtomicInteger 原子类,在 increase()方法中调用的 getAndIncrement()方法,它的底层实现就是 CAS 操作,查看源码,我们得知 this 即指向当前变量的内存地址,value 是 存储 AtomicInteger 的 int 值,该属性需要借助 volatile 关键字保证其在线程间是可见的,valueOffset 是存储 value 在 AtomicInteger 中的偏移量,1 是自增量。在 getAndAddInt()方法中通过 getIntVolatile()方法获取预期值,当 compareAndSwapInt()方法获得的当前值与预期值一致时,处理器将当前值更新为新值,不一致时当前值不断自增重试,直到更新成功为止。举个通俗点例子:比如你看到了一个可爱的小姐姐,你买好了花准备送她,但是却发现那个位置的小姐姐不是你原先看到的那个了,接着你就在人群中一个一个找,直到找到了才把花送到她手上,CAS 操作就跟你追求爱情一样执著。虽然 CAS 操作很高效,但是存在一个逻辑漏洞:如果第一次读取 V 的值为 A 值时,准备赋值的时候虽然检测到了它仍然是 A 值,但是在这个过程中可能被更改成了其他值,不过后来又改回了 A 值,但是 CAS 操作检测不出来,误认为没有发生过改变,这个称为“ABA”问题。J.U.C 的解决方式是控制变量的版本来保证 CAS 的正确性。


 private static final long valueOffset;
static { try { valueOffset = unsafe.objectFieldOffset (AtomicInteger.class.getDeclaredField("value")); } catch (Exception ex) { throw new Error(ex); } }
复制代码


  public final int getAndIncrement() {        return unsafe.getAndAddInt(this, valueOffset, 1);    }
复制代码


public final boolean compareAndSet(int expect, int update) {        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);    }
复制代码


public final int getAndAddInt(Object var1, long var2, int var4) {        int var5;        do {            var5 = this.getIntVolatile(var1, var2);        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5; }
复制代码


3.锁优化


Java 提供了种类丰富的锁,HotSpot 虚拟机团队花费了大量精力去实现各种锁优化技术,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率,解决竞争问题,进而提高程序的执行效率。


(1)自旋锁与自适应锁

自旋锁:让线程执行一个忙循环(自旋),自旋本身虽然避免了线程切换的开销,但是依然是要占用处理器时间的,如果锁被占用的时间很短,那么自旋等待的效率很高,反之,锁占用的时间很长,那么自旋的线程只会白白消耗 CPU 资源,反而带来了性能上的浪费。所以自旋等待的时间必须进行控制,自旋超过一定次数(默认 10 次,可以使用-XX:PreBlockSpin 来更改),仍然没有获得锁,就要挂起线程。在 JDK1.6 之后引入了自适应的自旋锁,自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。比如如果在同一个锁对象上,自旋等待刚刚获得过锁,并且持有锁的线程正在运行中,那么 JVM 久会认为这次自旋很有可能会再次成功。对于某个锁,如果很少获得成功,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。自旋锁的实现原理同样也是 CAS,AtomicInteger 中调用 unsafe 进行自增操作的源码中的 do-while 循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。



(2)轻量级锁

JDK1.6 之后引入的新型锁机制,要理解轻量级锁,就得从虚拟机的对象头的内存布局开始介绍,Hotspot 的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针),其中 Mark Word 用于存储对象自身的运行时数据,如 HashCode、GC 分代年龄等。它是实现轻量级锁的关键,后者则是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。Mark Word 的存储内容如下所示:


image.png


在代码进入同步块的时候,如果此对象没有被锁定,JVM 首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝。拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner 指针指向对象的 Mark Word。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为“00”,表示此对象处于轻量级锁定状态。如果轻量级锁的更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。如果有 2 个以上的线程争用同一个锁,那么轻量级锁将不再有效,会膨胀为重量级锁,Mark Word 中存储的是指向重量级锁(互斥量)的指针,后面等待的线程会进入阻塞状态。


(3)偏向锁

锁会偏向于第一个获得他的线程,如果在接下来的执行过程中,锁没有被其他线程获得过,那么持有锁的线程将不再需要进行同步。其目标就是在只有一个线程执行同步代码块时能够提高性能,当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储锁偏向的线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次 CAS 原子指令,而偏向锁只需要在置换 ThreadID 的时候依赖一次 CAS 原子指令。当另外一个线程去尝试获取这个锁时,持有偏向锁的线程才会释放锁,撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。


(4)重量级锁


轻量级锁升级为重量级锁时,锁标志的状态值变为“10”,此时 Mark Word 中存储的是指向重量级锁的指针,此时等待锁的线程都会进入阻塞状态。整体的锁状态升级流程如下:


通过以上分析,可以总结:偏向锁通过对比 Mark Word 解决加锁问题,避免执行 CAS 操作。而轻量级锁是通过用 CAS 操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。


发布于: 2020 年 05 月 18 日阅读数: 96
用户头像

颇风

关注

喜欢骑行、游泳、爬山的程序猿。 2020.04.19 加入

还未添加个人简介

评论

发布
暂无评论
JAVA内存模型与线程