JAVA内存模型与线程

23 小时前 阅读数: 13

一、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.04.19 加入

还未添加个人简介

评论

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