JVM-java内存模型及java关键字内存语义

1. JMM

1.1 什么是java内存模型

什么是java内存模型,就我理解,可以如上图所示。首先,因为java的内存模型是一个二级缓存,线程首先需要将数据写入到本地缓存,然后才能将本地缓存的数据刷到主内存中,所以就出现了可见性的和有序性的问题。由于cpu会进行优化,会进行cpu级别指令重排序。并且在java前端编译器中,也会对java代码进行优化,也有可能对代码进行重排序,这就导致了有序性问题。

为了解决上述问题,jmm是怎么做的呢?jmm会禁用掉部分指令重排序。同时针对java的二级缓存带来的问题,java会在部分指令之间加入内存屏障。经过上面的操作,java就给程序员提供了一个承诺,也就是8大happens-before原则。所以,我们在写代码的时候,需要遵循happens-before原则,才能保证代码的线程安全。

接下来,我们就聊聊上面的4个模块。

1.2 java的缓存模型和指令重排

1.2.1 java缓存模型

java的缓存模型如图所示,是一个二级缓存的结构,线程A如果想传递变量给线程B,会将值写入到本地缓存A,然后再刷入到共享缓存,线程B再从共享缓存中读入到线程B的本地缓存中。

所以这里就会有一个问题,比如对于变量X,在共享缓存中值为0,如果线程A将其更新为1,并写入到了线程A的本地缓存中,还未同步到共享缓存,时间片便被用完。线程B开始运行,从共享缓存读取变量A,这个时候读取到的应该是1。

上述就出现了,内存可见性问题,即对本地内存的写操作其实是只能保证是对本线程可见的。

1.2.3 指令重排序

1.编译期指令重排序

在java的前端编译器中,会有许多优化策略,所以可能对代码进行指令重排。

2.内存系统重排序

内存系统重排序就是,其实就是如1.2.1中所展示的那样,最终表现出来的也是代码的执行顺序是混乱的。

3.cpu指令级别重排序

cpu为了让多条指令重叠执行,所以对cpu的指令进行重排。

1.3 java针对上述问题所做的操作

1.3.1 代码级别

在编译期,禁用掉编译器的部分指令重排序。

1.3.2 cpu级别

在编译期,加入内存屏障,保证内存可见性。

以storeLoad屏障为例,如果加入store1 storeLoad load2的内存屏障,可以保证在执行load2操作的时候,store1的数据一定是刷入到共享内存中的,所以在进行load2操作时一定能够看到store1的数据。

1.4 happens-before原则

happens-before原则是jmm对程序员提供的承诺。

1.在单个线程中,书写在前面的操作,是对书写在后面的操作可见的;

2.对一个监视器的加锁是对这个监视器解锁可见的;

3.volatile变量的的写操作是对volatile的读操作可见的;

4.A happens-before B,B happens-before C,则A happens-before C;

5.线程启动对后续该线程操作可见;

6.线程所有操作对线程终止可见;

7.线程interrupt()方法的调用对线程中断事件检测可见;

8.对象的创建对对象的finalize()方法调用可见。

A happens-before B,并不代表A一定先于B执行,只是A的操作结果一定是对B可见的,比如就同一个线程中,书写在前面的操作,是对书写在后面的操作可见的这一个原则,因为jmm在保证单线程执行结果一致的情况下,也可能对指令进行重排。

2.long和double类型的特殊处理

java虚拟机规范规定可以不保证long类型和double类型的原子性。但是建议虚拟机实现的时候,需要保证这两个64位类型的原子性,现在市面上的大部分虚拟机都是如此实现的。

3.volatile关键字

3.1 volatile的写读的内存语义

volatile关键字的读写的内存语义其实就和对读写加锁的语义一致,比如如下代码其实是等效的:
 

volatile int l = 1;
public synchronized void set(long l) { //volatile关键字的写等价于对set方法进行加锁
    vl = l;
}
public synchronized void get() { //volatile关键字的读等价于对get方法进行加锁
    vl = l;
}

再从底层看volatile关键字的顺序性和可见性:

1. 对于java的二级缓存,volatile关键字的写,会将写之前的数据全部刷新到共享缓存中,所以在volatile写后面的读取数据一定能看到volatile写之间操作的数据;并且禁止volatile写之前的指令重排到volatile写之后(JSR133对volatile关键字内存语义的增强)。

2.volatile关键字的读,会使得本地缓存失效,直接获取共享缓存中的数据;并且禁止volatile读之前的指令重排到volatile读之后(JSR133对volatile关键字内存语义的增强)。

3.如果要实现线程间的通信,可以采用volatile关键字的读写来实现。通过1和2可以看出,如果先利用volatile写,后再利用volatile读,一定能保证写操作之间的数据是对读操作后的数据可见的。

4.volatile类型的符合操作是不具有原子性的,比如volatile++这种,是不具备原子性的。

3.2 volatile关键字与单例模式

如下是单例模式的双重锁模式的实现:
 

public class Singleton {
  	private volatile static Singleton INSTANCE; // 加 volatile
  
  	private Singleton () {}
  
  	public static Singleton getSingleton() {
        synchronized(Singleton.class) {         // 加 synchronized
            if (INSTANCE == null) {
                INSTANCE = new Singleton ();
            }
        }
      	return INSTANCE;
    }
}

那为什么这里需要将Instance加上volatile关键字呢?在解释这个问题之前,我们先看一下new一个对象的过程:

对象分配过程:

1.检测该对象的类是否被加载,如果为被加载,便去加载该类;

2.通过碰撞指针或者空闲列表为对象分配内存空间;

3.将对象属性设置为初始化值;

4.设置对象头;

5.执行对象的赋值操作和构造方法;

6.返回对象引用。

可以看出INSTANCE = new Singleton ()这一行代码执行了很多操作,所以如果发生指令重排的话,可能在第2步执行完成过后,就返回对象应用,这个时候的对象并不是一个预期的对象。如果加上volatile关键字,根据volatile读的内存语义可以知道,volatile读之前的操作一定是对volatile读可见,所以返回的一定是一个完整的对象。

4.final关键字

final关键字主要强调的是,构造函数和final域的可见性问题。

4.1 final域是普通类型

1.在返回包含final域的对象引用之前一定,完成了final域的赋值。

public class Test{
    private final int i = 1;
}
public static writer() {
    Test test = new Test();//在此处,一定完成了final域i的赋值
}

2.在读取fianl域的时候,一定完成了返回了对象引用。这条语义,对于hotspot虚拟机来说,普通变量也是生效的。

​
public class Test{
    private final int i = 1;
    public int geti() {
        return i;
    }
}
public static reader() {
    Test test = new Test();
    int j = test .geti();//在此处,一定完成了test引用的赋值
}

​

4.2 final域是对象类型

如果final域是引用类型,则在构造函数内对final域内引用的赋值一定是先于在构造函数外获取该引用。

5.synchronize关键字

5.1 synchronize关键字的使用

package common;

public class Test {
    public static synchronized void doSomething() {
        System.out.println("同步方法,类锁");
    }
    public synchronized void doSomething1() {
        System.out.println("同步方法,对象锁");
    }

    
    public synchronized void doSomething2() {
        synchronized (Test.class) {
            System.out.println("同步代码块,类锁");
        }

    }
    public synchronized void doSomething3() {
        synchronized (this) {
            System.out.println("同步代码块,方法锁");
        }
    }
}

可以看出,synchronize可以对类和对象加锁,类是也是一个Class对象,所以得出结论,synchronize其实是对class对象加锁。

5.2 synchronzied的实现原理

在对类进行编译的时候,会将同步方法设置为一个为ACC_SYNCHRONIZED,当线程访问该方法时,首先会后去到监视器锁,如果能够获取到监视器锁,便可以执行方法内的操作,并且在完成过后,便会释放该锁。注意,如果同步代码内部抛出异常,也会自动释放监视器锁。

什么是监视器锁呢?在C++的实现中,每个java对象都持有一个objectMonitor的对象,objectMonitor主要有如下几个属性:

_onwer:获取该对象的线程

_entryset:等待该锁的线程队列

_count:重入次数

如果想要进入代码块的时候,首先会进入到锁的等待队列中,如果后去到锁,便将count++,同时将onwer设置为该线程。这就是monitorenter指令的作用。

如果想要释放该锁,便会将owner设置为null,同时将count--,这就是monitorexit指令的作用。

从上面可以看出synchronized关键字是可重入锁。

参考

1.周志明.深入理解java虚拟机

2.程晓明.深入理解java内存模型

3.https://www.yuque.com/hollis666/un6qyk/gxq5p0


 

                         

  • 22
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值