JVM详解5

内存模型

1. java 内存模型(JMM)

Java内存模型和Java内存结构没关系

JMM 即 Java Memory Model,它定义了主存(共享内存)、工作内存(线程私有)抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。JMM 体现在以下几个方面:

  • 原子性: 保证指令不会受到线程上下文切换的影响。
  • 可见性:保证指令不会受 cpu 缓存的影响。
  • 有序性:保证指令不会受 cpu 指令并行优化的影响。

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

1.1 原子性

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

1.2 从字节码角度分析:

以上的结果可能是正数、负数、零. 因为 Java 中对静态变量的自增,自减并不是原子操作。这些操作可能被CPU交错执行
当i作为类的静态变量,根据字节码指令得,静态变量i++是通过执行iadd在操作数栈上自增的,而局部变量的i++是执行innc指令在局部变量表槽上执行的

Java的内存模型,静态变量的变化要在主存和线程内存中数据互换,不要与Java内存结构中的堆栈混淆,共享的变量信息放在主存,线程是在工作内存中

单线程下自增和自减的指令不会交错执行,不会有问题

多线程下自增和自减的指会交错执行,因为操作系统的线程模型都是抢先式多任务系统,线程会轮流拿到CPU的使用权,CPU会以时间片为单位将使用权分别交给两个线程

1.3 解决方法

Java内存模型保证原子性: 使用synchronized(同步关键字) 保证同步代码块内的代码执行的原子性

synchronized( 对象 ) {
	要作为原子操作代码
}

用 synchronized 解决并发问题:

static int i = 0;
static Object obj = new Object();

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int j = 0; j < 5000; j++) {
            synchronized (obj) {
            	i++;
            }
        }
    });
    Thread t2 = new Thread(() -> {
        for (int j = 0; j < 5000; j++) {
            synchronized (obj) {
            	i--;
            }
        }
    });
    
    t1.start();
    t2.start();
    
    t1.join();
    t2.join();

    System.out.println(i);
}

如何理解呢:你可以把 obj 想象成一个房间,线程 t1,t2 想象成两个人。

当线程 t1 执行到 synchronized(obj) 时就好比 t1 进入了这个房间,并反手锁住了门,在门内执行 count++ 代码。

这时候如果 t2 也运行到了 synchronized(obj) 时,它发现门被锁住了,只能在门外等待。

当 t1 执行完synchronized{} 块内的代码,这时候才会解开门上的锁,从 obj 房间出来。t2 线程这时才可以进入 obj 房间,反锁住门,执行它的 count-- 代码。

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

2 可见性

2.1 线程运行时退不出循环现象分析:

例子:

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不会如预想的停下来
}

首先 t 线程运行,然后过一秒,主线程设置 run 的值为 false,想让 t 线程停止下来,但是 t 线程并没有停!

说明 : main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止

分析 :

  • 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
  • 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高 速缓存中,减少对主存中 run 的访问,提高效率
  • 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读 取这个变量的值,结果永远是旧值

2.2 死循环的解决方法

引入volatile(易变关键字): 只保证可见性,不保证原子性

它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中查找它的最新值,线程操作 volatile 变量每次都是直接到主存读取,保证线程每次看到都是最新结果。

 public static volatile boolean run = true; // 保证内存之间的可见性

前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一 个线程可见,不能保证原子性仅用在一个写线程,多个读线程的情况

上例从字节码理解是这样的:

getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
putstatic run // 线程 main 修改 run 为 false, 仅此一次
getstatic run // 线程 t 获取 run false

比较一下之前我们将线程安全时举的例子:两个线程一个i++ 一个 i-- ,只能保证看到最新值,不能解 决指令交错

注意:

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

如果在前面示例的死循环中加入 System.out.println() 会发现即使变量不加 volatile 修饰符,线程 t 也 能正确看到对 run 变量的修改了,想一想为什么?(因为println()源码中有synchronized代码块加锁,可以保证原子性与可见性,它是 PrintStream 类的方法

3.有序性

同一线程内,指令重排不会影响到结果的正确性;多线程下

多线程下指令重排的问题

int num = 0;

// volatile 修饰的变量,可以禁用指令重排 volatile boolean ready = false; 可以防止变量之前的代码被重排序
boolean ready = false; 
// 线程1 执行此方法
public void actor1(I_Result r) {
 if(ready) {
 	r.r1 = num + num;
 } 
 else {
 	r.r1 = 1;
 }
}
// 线程2 执行此方法
// 发生指令重排可能会只给ready赋值,跳过num的赋值,直接跳到线程1
public void actor2(I_Result r) {
 num = 2;
 ready = true;
}

发生指令重排可能会只给ready赋值,跳过num的赋值,直接跳到线程1

解决指令重排方法 :

给要禁止指令重排的变量添加volatile修饰符

单例模式的懒汉式虽然是双重锁检测机制,但是也可能会出现重排序的问题,根据字节码指令可以看到单例对象创建的时候,线程1先将对象引用地址复制给静态变量(指令重排),此时静态不为空,然后线程2进来后没来得及进入同步代码块,if判断条件判断对象不为空,线程2直接返回静态变量,但是线程1还没执行完构造方法,线程2拿到的就是不完整构造的对象

解决方法 : 给静态变量加volatile修饰符

注意 : volatile 修饰要在jdk1.5以上才管用

volatile 原理
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
对 volatile 变量的写指令后会加入写屏障
对 volatile 变量的读指令前会加入读屏障

如何保证可见性?

写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中

而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据:

如何保证有序性?

写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

注意:

volatile 不能解决指令交错

写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读跑到它前面去

而有序性的保证也只是保证了本线程内相关代码不被重排序

4.CAS与原子类

CAS(即Compare And Swap)是与volatile配合使用的技术,体现一种乐观锁的思想,被称作为无锁并发;保护共享变量无需加锁

CAS作用 : 抢锁的时候,对mark word 进行比较并交换,无锁并发,保证原子性

CAS机制原理:

例子 : 多线程对共享变量执行+1操作

首先,通过开启死循环,从主内存读取共享变量赋值给工作内存中的一个旧值变量,在一条线程内旧值+1操作,得到一个结果变量,然后调用CompareAndSwap方法,该方法会尝试把结果赋值给共享变量,同时会先让旧值和共享变量当前值作比较,因为怕写入结果时候有其他线程把共享变量值改成了新值,所以要拿上次读到的旧值和当前共享变量值作比较,若比较结果一致,就可以成功把结果值写入到共享变量中,若不一致,则说明其他线程把共享变量改了,这一次尝试就失败了,方法失败后返回false,就会从新进入while循环,再读取共享变量的最新值重复刚刚的操作.
所以 CAS 采用的是不断尝试的机制直到成功

获取共享变量时,为了要获取最新结果,保证变量的可见性,所以CAS要和volatile结合使用,适用于竞争不激烈,多核CPU的场景,若竞争激烈可能会频繁重试,效率降低;
CAS线程一直跑,没有上下文切换,所以不会有阻塞,低竞争会比synchronized效率高

4.2 乐观锁和悲观锁

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系, 我吃亏点再重试呗。
  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁 你们都别想改,我改完了解开锁,你们才有机会。
  • 原子操作类:juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、 AtomicBoolean等,它们底层就是采用 CAS 技术 + volatile 来实现的。

5.synchronized的优化

Java HotSpot 虚拟机中,每个对象都有对象头(其中有两个部分包括 class 指针和 Mark Word)。Mark Word 平时存
储这个对象的 哈希码 、 分代年龄 (新生代晋升到老年代会用到),当给对象加synchronized锁时,这些对象头信息就根据情况被替换为 标记位 (加的是什么锁),根据锁类型不同的不同存储、 线程锁记录指
针 、 重量级锁指针 、 线程ID 等内容

5.1 轻量级锁

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

5.5 锁的其他优化

读写分离

读操作不用同步,写操作才用同步

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值