JAVA内存模型基础解释

  • Post author:
  • Post category:java


并发编程

在并发编程领域,有两个关键问题:线程之间的

通信



同步

线程之间的通信

线程通信的目的是为了能够


让线程之间相互发送信号。另外,线程通信还能够使得线程等待其它线程的信号,


比如,线程B可以等待线程A的信号,这个信号可以是线程A已经处理完成的信号。线程之间的通信有两种机制,为通过共享变量通信与消息传递




  1. 共享内存的并发模型


    里,线程之间共享程序的公共状态,线程之间


    通过写-读内存中的公共状态来隐式进行通信


    ,典型的共享内存通信方式


    就是通过共享对象进行通信





  2. 消息传递的并发模


    型里,


    线程之间没有公共状态


    ,线程之间必


    须通过明确的发送消息来显式进行通信


    ,在java中典型的消息传递方式就是wait()和notify()


更多详情解释

线程之间的同步

同步是指程序用于控制


不同线程之间操作发生相对顺序


的机制。




  1. 共享内存并发模型


    里,


    同步是显式进行的


    。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。



  2. 消息传递的并发模型


    里,由于消息的发送必须在消息的接收之前,因此


    同步是隐式进行的


JAVA内存模型

Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。



java内存模型


即Java Memory Model,简称为JMM,定义了java虚拟机(JVM)在计算机内存中的工作方式,Jvm是整个计算机虚拟模型,所以说,JMM是JVM一部分,与JVM内存结构完全不同。


它规定了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障



线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本




根据上图两线程之间互相通信的步骤为:首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。 2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

原子性:

eg:,两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

由于



静态变量的自增,自减不是原子操作



,故其会出现三种情况。

  • 出现负数:
// 假设i的初始值为0 
getstatic     i  // 线程1-获取静态变量i的值 线程内i=0 
getstatic     i  // 线程2-获取静态变量i的值 线程内i=0 
iconst_1         // 线程1-准备常量1 
iadd             // 线程1-自增 线程内i=1 
putstatic     i  // 线程1-将修改后的值存入静态变量i 静态变量i=1 
iconst_1         // 线程2-准备常量1 
isub             // 线程2-自减 线程内i=-1 
putstatic     i  // 线程2-将修改后的值存入静态变量i 静态变量i=-1
  • 出现正数:
// 假设i的初始值为0 
getstatic     i  // 线程1-获取静态变量i的值 线程内i=0 
getstatic     i  // 线程2-获取静态变量i的值 线程内i=0 
iconst_1         // 线程1-准备常量1 
iadd             // 线程1-自增 线程内i=1 
iconst_1         // 线程2-准备常量1 
isub             // 线程2-自减 线程内i=-1 
putstatic     i  // 线程2-将修改后的值存入静态变量i 静态变量i=-1 
putstatic     i  // 线程1-将修改后的值存入静态变量i 静态变量i=1
  • 结果为零



解决办法


: 使用



synchronized



关键字修饰关键代码块

注意:上例中两个线程线程必须用 synchronized 锁住同一个 obj 对象,如果 线程1 锁住的是 m1 对 象,线程2锁住的是 m2 对象,就好比两个人分别进入了两个不同的房间,没法起到同步的效果。

可见性:

eg:

在此案例中,线程T并不会退出,而是一直在运行?

public class Main {
	static boolean run = true;
	public static void main(String args[]) throws InterruptedException {
		Thread t = new Thread((){
				       while(run){
					}
				});
		t.start();
		Thread.sleep(1000);
		run = false; // 线程t不会如预想的停下来
	}
}



原因:

在初始状态,线程会从朱内存中读取run的值到工作内存中。

因为t线程要频繁的从朱内存中读取run的值,即时编译器会将run的值缓存至自己工作内存中的高速缓存中去,减少对主存中run的访问,提高效率。

在1秒之后,main线程修改了run的值,并同步至主存,而线程t是从自己的工作内存的告诉缓冲中读取这个变量的值,结果永远为true。所以一直运行



解决方案


: 用关键字



volatile修饰



。它可以用来修饰成员变量和静态成员变量,他可以



避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值



,线程操作 volatile 变量都是直接操作主存



注意:synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但 缺点是synchronized是属于重量级操作,性能相对更低



如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也 能正确看到对 run 变量的修改了,因为System.out.println是线程安全的,用synchronized修饰。


有序性:

eg:

int num = 0;
boolean ready = false;
// 线程1 执行此方法 
public void actor1(I_Result r) {
    if(ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
 }
// 线程2 执行此方法 
public void actor2(I_Result r) {
    num = 2;
    ready = true;
 }

可能有三种结果:

  • 线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
  • 线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结 果为1
  • 线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过 了)


  • 线程2先执行ready=true,接着线程1执行,线程1执行完后,执行线程2语句

    (由于指令重排。指令重排,是 JIT 编译器(及时编译器)在运行时的一些优化,这个现象需要通过大量测试才能复现)




解决方法:





volatile



修饰的变量,可以禁用指令重排



指令重排的理解:可能会优先执行不费时的操作。

单线程情况下,指令重排对运行结果没有影响。但是在多线程下『指令重排』会影响正确性,例如著名的 double-checked locking 模式实现单例:

public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;
    public static Singleton getInstance() {
        // 实例没创建,才会进入内部的 synchronized代码块
        if (INSTANCE == null) {
              synchronized (Singleton.class) {
              // 也许有其它线程已经创建实例,所以再判断一次
              if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
 }

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁

但在多线程环境下,上面的代码是有问题的, INSTANCE = new Singleton() 对应的字节码为:

0: new           #2                  // class cn/itcast/jvm/t4/Singleton 
3: dup 
4: invokespecial #3                  // Method "<init>":()V 
7: putstatic     #4                  // Field 
INSTANCE:Lcn/itcast/jvm/t4/Singleton;
时间1  t1 线程执行到 INSTANCE = new Singleton(); 
时间2  t1 线程分配空间,为Singleton对象生成了引用地址(0 处) 
时间3  t1 线程将引用地址赋值给 INSTANCE,这时 INSTANCE != null(7 处) 
时间4  t2 线程进入getInstance() 方法,发现 INSTANCE != null(synchronized块外),直接返回INSTANCE 
时间5  t1 线程执行Singleton的构造方法(4 处)

这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将 是一个未初始化完毕的单例



解决方法:

对 INSTANCE 使用 volatile 修饰



即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才 会真正有效




synchronized与volatile的区别


happens-before

happens-before 规定了哪些


写操作对其它线程的读操作可见


,它是可见性与有序性的一套规则总结, 抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见

  1. 线程对volatile变量的写,对接下来其他线程对该变量的读可见
  2. 解锁线程m之前对变量的写,对于接下来对m加锁的其他线程对该变量的读可见
  3. 线程start前对变量的写,对该线程开始后对该变量的读可见
  4. 线程结束前对变量的写,对其他线程得知它结束后的读可见(t1.isAlive或t1.join)
  5. 线程t1打断t2前对变量的写,对于其他线程得知t2被打断后对变量的读可见(通过t2.interrupted或t2.isInterrupted)


  6. 对变量默认值(,null,false.0)的写,对其他线程的对该变量的读可见

  7. 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z

CAS与原子性

CAS:

CAS 即 Compare and Swap ,它体现的一种乐观锁的思想,比如多个线程要对一个共享的整型变量执 行 +1 操作:

// 需要不断尝试 
while(true) {   
    int 旧值 = 共享变量 ; // 比如拿到了当前值 0  
    int 结果 = 旧值 + 1; // 在旧值 0 的基础上增加 1 ,正确结果是 1     
    /*     这时候如果别的线程把共享变量改成了 5,本线程的正确结果 1 就作废了,这时候
        compareAndSwap 返回 false,重新尝试,直到:
        compareAndSwap 返回 true,表示我本线程做修改的同时,别的线程没有干扰  */
    if( compareAndSwap ( 旧值, 结果 )) {
         // 成功,退出循环  
    } 
}



包含三个参数,compareAndSwap(v,e,n),分别表示要更新的变量(主内存的值),预期值,新值。




当且仅当 V 值等于 E 值时,才会将 V 的值设为 N,如果 V 值和 E 值不同,则说明已经有其他线程做了更新,则当 前线程什么都不做。最后,CAS返回当前V的真实值。 CAS

获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。结合 CAS 和 volatile 可以实现无 锁并发,适用于竞争不激烈、多核 CPU 的场景下。

  • 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
  • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响

乐观锁与悲观锁

  • CAS 是基于乐观锁的思想:乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系, 我吃亏点再重试呗。
  • synchronized 是基于悲观锁的思想:悲观的估计,得防着其它线程来修改共享变量,我上了锁 你们都别想改,我改完了解开锁,你们才有机会。

原子操作类

juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:


AtomicInteger、AtomicBoolean


等,它们底层就是采用



CAS 技术 + volatile



来实现的。

Synchronized优化

对象头:

在这里插入图片描述

Java HotSpot 虚拟机中,每个对象都有对象头。第一个字宽也被称为对象头

Mark Word

。 对象头包含了多种不同的信息, 其中就包含对象锁相关的信息。第二个字宽是指向定义该对象类信息(class metadata)的指针

在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。lock Record中有两部分,一部分存储着对象头的Marh Word信息,另一部分,存储着mark Object的地址指针

轻量级锁:



如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化


。这就好比:

学生(线程 A)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明没有竞争,继续上他的课。 如果这期间有其它学生(线程 B)来了,会告知(线程A)有并发访问,线程 A 随即升级为重量级锁,进入重量级锁的流程。



轻量级锁的加锁过程:

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图2.1所示。
  2. 拷贝对象头中的Mark Word复制到锁记录中。
  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5)。
  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,



    并且对象Mark Word的锁标志位设置为“00”



    ,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图2.2所示。
  5. 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。


eg:

static Object obj = new Object(); 
public static void method1() {
    synchronized( obj ) {
        // 同步块 A
        method2();
    }
 } 
public static void method2() {
    synchronized( obj ) {
        // 同步块 B
    }
 }



运行流程:

访问同步代码块,将Mark Word复制到线程1的锁记录中 01(无锁)
CAS修改Mark为线程1的锁记录地址 01(无锁)
加锁成功 00(轻量级锁) 线程1锁记录地址
访问同步代码块A 00(轻量级锁) 线程1 锁记录地址
访问同步快B,将Mark复制到线程1的锁记录中 00(轻量级锁) 线程1锁记录地址
CAS修改Mark为线程1的锁记录地址 00(轻量级锁) 线程1锁记录地址
失败(发现是自己的锁) 00(轻量级锁) 线程1锁记录地址
锁重入 00(轻量级锁) 线程1锁记录地址
执行同步快B 00(轻量级锁) 线程1锁记录地址
同步块B执行完毕 00(轻量级锁) 线程1锁记录地址
同步块A执行完毕 00(轻量级锁) 线程1锁记录地址
成功(解锁) 01(无锁)
01(无锁) 访问同步块A ,将Mark复制到线程2的锁记录中
01(无锁) CAS修改Mark为线程2锁记录地址
00(轻量级锁) 线程2锁记录地址 成功(加锁)

锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁



线程



1



对象



Mark



线程



2


访问同步块,把


Mark


复制到线程


1


的锁记录


01


(无锁)




CAS


修改


Mark


为线程


1


锁记录地 址


01


(无锁)




成功(加锁)


00


(轻量锁)线程


1


锁 记录地址




执行同步块


00


(轻量锁)线程


1


锁 记录地址




执行同步块


00


(轻量锁)线程


1


锁 记录地址


访问同步块,把


Mark


复制 到线程


2


执行同步块


00


(轻量锁)线程


1


锁 记录地址


CAS


修改


Mark


为线程


2


锁 记录地址


执行同步块


00


(轻量锁)线程


1


锁 记录地址


失败(发现别人已经占了 锁)


执行同步块


00


(轻量锁)线程


1


锁 记录地址


CAS


修改


Mark


为重量锁


执行同步块


10


(重量锁)重量锁指 针


阻塞中


执行完毕


10


(重量锁)重量锁指 针


阻塞中


失败(解锁)


10


(重量锁)重量锁指 针


阻塞中


释放重量锁,唤起阻塞线程竞争


01


(无锁)


阻塞中




10


(重量锁)


竞争重量锁




10


(重量锁)


成功(加锁)







重量锁

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退 出了同步块,释放了锁),这时当前线程就可以避免阻塞。

在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能 性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能

  • 自旋成功:



线程



1







cpu 1



上)



对象



Mark



线程



2







cpu 2



上)




10


(重量锁)




访问同步块,获取


monitor


10


(重量锁)重量锁指针




成功(加锁)


10


(重量锁)重量锁指针




执行同步块


10


(重量锁)重量锁指针




执行同步块


10


(重量锁)重量锁指针


访问同步块,获取


monitor


执行同步块


10


(重量锁)重量锁指针


自旋重试


执行完毕


10


(重量锁)重量锁指针


自旋重试


成功(解锁)


01


(无锁)


自旋重试




10


(重量锁)重量锁指针


成功(加锁)




10


(重量锁)重量锁指针


执行同步块







  • 自旋失败:



线程



1







cpu 1



上)



对象



Mark



线程



2







cpu 2



上)




10


(重量锁)




访问同步块,获取


monitor


10


(重量锁)重量锁指针




成功(加锁)


10


(重量锁)重量锁指针




执行同步块


10


(重量锁)重量锁指针




执行同步块


10


(重量锁)重量锁指针


访问同步块,获取


monitor


执行同步块


10


(重量锁)重量锁指针


自旋重试


执行同步块


10


(重量锁)重量锁指针


自旋重试


执行同步块


10


(重量锁)重量锁指针


自旋重试


执行同步块


10


(重量锁)重量锁指针


阻塞








偏向锁

引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。上面说过,轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。


偏向锁获取过程:

(1)访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01——确认为可偏向状态。

(2)如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤(5),否则进入步骤(3)。

(3)如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行(4)。

(4)如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。

(5)执行同步代码。


偏向锁的释放:



偏向锁的撤销在上述第四步骤中有提到



偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

eg

static Object obj = new Object();
public static void method1() {
    synchronized( obj ) {
    // 同步块  A
    method2();
    }
}
public static void method2() {
    synchronized( obj ) {
    // 同步块  B
    }
}



线程



1



对象



Mark


访问同步块


A


,检查


Mark


中是否有线程


ID


101


(无锁可偏向)


尝试加偏向锁


101


(无锁可偏向)对象


hashCode


成功


101


(无锁可偏向)线程


ID


执行同步块


A


101


(无锁可偏向)线程


ID


访问同步块


B


,检查


Mark


中是否有线程


ID


101


(无锁可偏向)线程


ID


是自己的线程


ID


,锁是自己的,无需做更多操作


101


(无锁可偏向)线程


ID


执行同步块


B


101


(无锁可偏向)线程


ID


执行完毕


101


(无锁可偏向)对象


hashCode

锁的优化

  1. 减少上锁时间 :同步代码块中尽量短
  2. 减少锁的粒度 :将一个锁拆分为多个锁提高并发度,例如:LinkedBlockingQueue 入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高
  3. 锁粗化 :多次循环进入同步块不如同步块内多次循环 另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次
new StringBuffer().append("a").append("b").append("c")
  1. 锁消除:JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候就会被即时编译器忽略掉所有同步操作。
  2. 读写分离 :CopyOnWriteArrayList ConyOnWriteSet

参考文件:


https://blog.csdn.net/suifeng3051/article/details/52611310


https://blog.csdn.net/suifeng3051/article/details/52611233


https://blog.csdn.net/lengxiao1993/article/details/81568130



版权声明:本文为weixin_44545800原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。