深入理解Java虚拟机系列(四)--Java内存模型和线程

系列文章目录

深入理解Java虚拟机系列文章

一.Java内存模型

Java内存模型用来干啥的?是用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各个平台下都能达到一致的内存访问效果。

1.1 主内存和工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
注:这里的变量指的是实例字段、静态字段和构成数组对象的元素。(线程共有的)

Java内存模型规定:

  1. 所有的变量都存储在主内存中。
  2. 每条线程有属于自己的工作内存,其中保存了被该线程使用到的变量的主内存副本拷贝
  3. 线程对变量的所有操作都必须在工作内存当中进行,而不能直接读写主内存中的变量
  4. 不同的线程之间无法直接访问对方工作内存中的变量(私有),线程间变量值的传递需要通过主内存来完成。

关系图如下:
在这里插入图片描述
从变量、主内存和工作内存的定义来看可以说:

  • 主内存主要对应于Java堆中的对象实例数据部分。
  • 工作内存则对应于虚拟机栈中的部分区域。

1.2 内存间相互操作和约定

上文提到过,线程之间的数据操作,必须通过主内存来实现,也就是说,工作内存和主内存之间会存在一个交互动作,那自然而然也有它的交互协议(即规则)。因此,Java内存模型中定义了以下8种操作来完成,并且这些操作都是原子操作、不可再分。

作用于主内存变量的操作:

  • lock(锁定):把一个变量标识为一条线成独占的状态。
  • unlock(解锁):把一个处于锁定状态的变量释放出来,释放之后的变量才能够被其他线程锁定。
  • read(读取):把一个变量的值从主内存传输到线程的工作内存当中,以便之后的load动作使用。

作用于工作内存变量的操作:

  • load(载入):把read操作从主内存中得到的变量放入到工作内存的变量副本当中
  • use(使用):把工作内存当中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码执行时会执行这个操作。
  • assign(赋值):把一个从执行引擎接收到的值赋值给工作内存中的变量。
  • store(存储):把工作内存中一个变量的值传递到主内存当中。
  • write(写入):把store操作从工作内存当中得到的变量的值放入主内存的变量中。

当然,应当注意到,这8个操作虽然是原子性的,那么对于多个原子操作,他们之间的执行顺序也是对结果有影响的,因此这8个基本操作还需要满足以下规则:

  • 不允许read、load、store、write操作之一单独出现,即不允许一个变量从主内存当中读取了但工作内存不接受或者相反的情况。
  • 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存当中改变之后必须把它同步到主内存当中。
  • 若想把工作内存中的变量同步于主内存之中,必须经过assign操作。
  • 一个新变量只能在主内存中出生,若想在工作内存当中使用一个新变量,必须先经过assign和load操作。
  • 一个变量在同一个时刻只允许一条线程对其进行lock操作(可重复),执行N次lock,则需要执行N次unlock,变量才能被解锁。
  • 若对一个变量执行了lock操作,那么会清空工作内存当中该变量的值。
  • 若一个变量事先没有被lock操作锁定,就不允许对他进行unlock操作。
  • 对一个变量执行unlock操作之前,必须把这个变量同步到主内存当中(使用store和write)。

1.3 volatile

关键字volatile是Java虚拟机提供的最轻量级的同步机制。当一个变量定义为volatile后,它具备两种特性:

  1. 可见性:当一条线程修改了volatile修饰的变量的值,那么该新值对于其他线程来说立即可见。
  2. 禁止指令重排序。

1.3.1 volatile不可保证原子性

这里需要说明一点:volatile虽然保证了可见性,但是不能保证原子性。
为什么?首先先举个例子:
案例1:

public class VolatileTest {
    
    
    public static volatile int a = 0;

    public static void increase() {
    
    
        a++;
    }

    private static final int THREAD_COUNT = 10;

    public static void main(String[] args) {
    
    
        Thread[] threads = new Thread[THREAD_COUNT];
        for (int i = 0; i < threads.length; i++) {
    
    
            threads[i] = new Thread(new Runnable() {
    
    
                @Override
                public void run() {
    
    
                    for (int j = 0; j < 1000; j++) {
    
    
                        increase();
                    }
                }
            });
            threads[i].start();
        }

		// 理论上会打印出10000
        System.out.println(a);
    }
}

实际结果:并且每次都不一样,始终无法达到10000。
在这里插入图片描述
接下来准备从这段代码的字节码指令角度来说。

插曲1:利用idea进行反编译

这里大家可以用javap进行反编译,我这里简单的教一下在idea上咋搞。
第一步:
在这里插入图片描述
第二步:
具体配置图:
在这里插入图片描述名字:Show Byte Code
Arguments:-v $FileClass$
Working directory:$OutputPath$
至于安装路径,如果跟我一样是mac的小伙伴可以在终端输入:

/usr/libexec/java_home -V

即可查看对应的jdk安装目录
在这里插入图片描述
第三步:点击刚配置的工具即能把反编译出的字节码拿到手啦。
在这里插入图片描述
编译结果:

public static void increase();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
     	 // 发现原本的a++在这里拆成了4条字节码指令
         0: getstatic // 将a的值取到操作栈顶,而volatile关键字就保证了a的值在此时此刻是正确的
         3: iconst_1 // 在进行加法的时候,加入这个a值原本在这里应该是100的基础上+1
         4: iadd // 但是,可能别的线程已经把a的值加到了200,那次是操作栈顶的数据就是过期的数据了。那么此时的数据就产生了不一致性
         5: putstatic // 这个时候可能把较小的值同步到主内存当中
         8: return
      LineNumberTable:
        line 7: 0
        line 8: 8

这里可以说明,volatile不能保证原子性。那回过头来,我再说明下为什么保证了可见性:
每次访问被volatile声明的变量,线程的工作局内存都会进行一次刷新,从主内存当中去获取变量,即获取最新的值,然后再把最新的值赋值于自己工作内存当中的副本拷贝。也因此,每个线程在访问经过volatile修饰的变量的时候,拿到的值是最新的。

volatile的实现原则有两点:

  1. 引起处理器缓存回写到主内存当中。
  2. 一个处理器的缓存会写到内存中这个动作,导致其他处理器的缓存无效。

1.3.2 volatile禁止指令重排

再讲指令重排之前,请允许我再来一个小插曲

插曲2:idea生成JIT汇编代码(mac)

第一步:mac版本的相关文件下载:
hsdis-amd64.dylib,密码:2vv3

第二步:把这个文件复制到对应目录下(认准)jre/lib/server即可:
这是我的mac安装的jdk目录(关于安装路径的查找可以看上文):

/Library/Java/JavaVirtualMachines/jdk1.8.0_251.jdk/Contents/Home/jre/lib/server

第三步:在idea当中,给对应的类设置VM属性

-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp -XX:CompileCommand=dontinline,*Singleton.getInstance -XX:CompileCommand=compileonly

第四步:跑代码即可,控制台会自动输出对应的JIT编码。

回到volatile禁止指令重排的问题:
问题1:什么是指令重排?

指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。

问题2:指令重排的目的是什么?

指令重排的目的是为了在不改变程序执行结果的前提下,优化程序的运行效率。需要注意的是,这里所说的不改变执行结果,指的是不改变单线程下的程序执行结果。

也因此指令重排在高并发下会影响到多线程的执行结果,而volatile指令的作用之一就是禁止了指令重排,接下来会给一个案例来说明他是如何禁止的。

案例2:双重检索的单例模式

public class Singleton {
    
    
    private volatile static Singleton instance;

    public static Singleton getInstance() {
    
    
        if (instance == null) {
    
    
            synchronized (Singleton.class) {
    
    
                if (instance == null) {
    
    
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
    
    
        Singleton.getInstance();
    }
}

生成的部分汇编代码:
在这里插入图片描述
我自己对比了一下,如果把volatile删掉,生成的汇编代码中,是不会存在上图中红色方框中的内容的。

换句话说变量经过volatile修饰后,会多一个lock addl $0x0的操作,而这个操作相当于一个内存屏障

问题3:什么是内存屏障?

内存屏障,也称为内存栅栏,是一个cpu的指令,他有俩作用:
1.是保证特定操作的执行顺序;
2.是保证某些变量的内存可见性(volatile的内存可见性是利用该特性实现的)

然后再结合volatile的可见性,上文提到volatile有两个实现原则,结合这个汇编指令再来做一个总结:

  1. 加了volatile修饰的变量,处理器会多出一个带有Lock的汇编指令
  2. 而Lock前缀指令主要做了两件事情:
  3. 第一个:将当前处理器缓存行的数据回写到内存当中。第二个:这个写回内存的操作会使其他CPU缓存了该内存地址的数据无效。(内存屏障的功能之一)
  4. 换句话说,lock指令加了一个内存屏障,由于编译器和处理器都能执行指令重排优化,如果在指令之间插入一条内存屏障则会告诉编译器和cup不管在任何情况下,无论任何指令都不能和这条内存屏障进行指令重排,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。

再回到这个案例2,那么为什么要加入这个volatile来修饰?

  1. instance = new Singleton();并不是一个原子操作,会被编译成三条指令(按顺序)。1.给instance分配内存。2.初始化其构造器。3.将instance对象指向分配的内存空间。
  2. 也因此在多线程情况下,如果不加入volatile,可能会发生重排序,导致可能的执行顺序是132,从而导致某些线程访问到未初始化的变量。
  3. 也因此用volatile来禁止重排序,通过加入内存屏障的方式来保证执行的顺序为123。

1.3.3 volatile的小总结

  1. volatile不能保证原子性(案例1)。
  2. volatile保证了可见性,并且能够禁止指令重排(有序性)。
  3. 以上两点的实现是基于内存屏障的插入。(经过volatile修饰,则多出一个汇编指令lock,lock的作用相当于内存屏障)

1.4 原子性、可见性和有序性

上文提到了3个词汇:原子性、可见性和有序性。而Java内存模型是围绕在并发过程中如何处理这3个特征来建立的。这里我们就介绍下有哪些操作实现了这3个特性。

  • 原子性

Java内存模型直接保证的原子性变量操作包括:read、load、assign、use、store、write。(例外:long和double的非原子性协定)
此外,synchronized关键字也具备了原子性。(这也是synchronized和volatile的一个重要区别)

  • 可见性

可见性指一个线程修改了共享变量的值,其他线程能够立即得知这个修改。而其实现是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的。

而volatile的特殊规则能够保证新值能够立即同步到主内存当中,并且每次使用前立即从主内存刷新。除此之外,synchronized和final也能够实现可见性。

  • 有序性

Java程序中天然的有序性可以概括为:如果在本线程内观察,所有的操作都是有序的。
Java提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。

1.4.1 Synchronized和volatile的比较

  • volatile不会进行加锁操作(它只是一种稍弱的同步机制),而Synchronized会进行加锁。
  • volatile变量作用类似于同步变量的读写操作,从内存可见性角度来看:1.写入volatile变量——>退出同步代码块。2.读取volatile变量——>进入同步代码块。
  • volatile不如Synchronized安全(前者无锁,后者有)
  • volatile不能同时保证内存可见性和原子性,但是Synchronized可以。

二.Java与线程

2.1 线程的状态

Java语言定义了5种线程状态,在任意一个时间点,一个线程只能有其中的一种状态,5种状态如下:

  • 新建(New):创建后尚未启动的线程处于该状态。
  • 运行(Runnable):此状态的线程有可能正在执行,也有可能正在等待这CPU为他分配执行时间(Ready)。
  • 无限期等待(Waiting):处于该状态的线程不会被分配CPU执行时间,他们要等待被其他线程显式的唤醒。

以下方法会让线程陷入无限期等待状态:
1.没有设置Timeout参数的Object.wait()方法
2.没有设置Timeout参数的Thread.join()方法
3.LockSupport.park()

  • 限期等待(Timed Waiting,和第三条属于同一类):处于这种状态的线程不会被分配CPU执行时间,会在一定时间后由系统自动唤醒。
  • 阻塞(Blocked):等待着获取到某个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生。
  • 结束:已经终止的线程,

以上状态在遇到特定事件的时候会互相转换,如下图:
在这里插入图片描述

2.2 线程安全

先来讲下什么是线程安全:

当多个线程访问一个对象的时候,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。

接下来再讲一下线性安全的实现方法。

2.2.1 互斥同步

互斥同步是常见的一种并发正确性的保障手段。其中,同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只能被一个线程使用。 而互斥则是实现同步的一种手段,例如:

  • 临界区
  • 互斥量
  • 信号量

而在Java中,最基本的互斥同步手段就是Synchronized关键字,Synchronized在经过编译后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令。这两个字节码都需要一个Reference类型的参数来指明要锁定和解锁的对象。例如:synchronized(obj),那么引用类型就是这个obj。

其原理:

  • 在执行monitorenter指令时,首先要尝试获取对象的锁,若对象没有被锁定,或者当前线程已经拥有了那个对象的锁,此时把锁的计数器+1。(也因此synchronized也属于可重入锁)
  • 对应的,在执行monitorexit指令时,锁的计数器会减1。
  • 当计数器为0的时候,锁被释放。若获取对象锁失败,则当前线程进入堵塞状态,直到对象锁被另外一个线程释放为止。

此外,我们还可以用JUC包下面的另一个重入锁:ReentrantLock来实现同步。(这个具体的就不去赘述)这里对ReentrantLock和synchronized做一个比较,也是面试经常容易问到的一个考点:

ReentrantLock和synchronized的区别是什么?
1.代码写法上:

  • synchronized表现为原生语法层面的互斥锁,一般绑定对象,或者修饰方法。
  • ReentrantLock表现为API层面的互斥锁,需要通过lock()和unlock()方法来加锁解锁。

2.功能上:

  • 两者差不多,但是ReentrantLock增加了一些高级功能,主要有3个:等待可中断、可实现公平锁、锁可以绑定多个事件。(两者默认情况下都是非公平锁)

3.性能以及其他问题上:

  • synchronized托管于JVM执行,性能较差,但语义清晰。
  • ReentrantLock主要有我们用户来控制(代码层面)。

1.等待可中断:是指当尺有所的线程长期不释放锁的时候,正在等待的线程可以放弃等待,改为处理其他事情,而不是无限期的等待其释放锁。
2.1公平锁:是指多个线程在等待同一个锁的时候,必须按照申请锁的时间顺序来依次获取锁。
2.2非公平锁:无法保证上述的点,锁被释放的时候,任何一个等待锁的线程都有机会获得锁。
3.锁绑定多个事件:是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()、notify()等方法可以实现一个隐含的条件,但是如果要和多个条件关联的时候,就不得不额外的添加一个锁,而ReentrantLock只需要多次调用newCondition()方法即可。

2.2.2 非阻塞同步

其实,互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,也因此这种方式也叫做阻塞同步。那相应的也就有非阻塞同步,即通过乐观的并发策略,在遇到堵塞的时候,不把线程挂起,而是通过不断的重试直到成功的这种方式。CAS就是其中的一种。

CAS(Compare and Swap):比较并交换。CAS指令有3个操作数:

  • 内存位置:变量的内存地址,用V表示。
  • 旧的预期值:用A表示。
  • 新值:B表示。

则CAS执行的时候,当且仅当V符合旧预期值A时,处理器用B更新V的值,否则不执行更新。并且无论是否更新了V的值,都会返回V的值。(这一整个操作属于原子操作)Java的CAS操作由sun.misc.UnSafe类中的compareAndSwapInt()等几个方法包装提供。

举个例子:把上面的案例的volatile和int更改为一个AtomicInteger类。

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicTest {
    
    
    public static AtomicInteger a = new AtomicInteger(0);

    private static final int THREAD_COUNT = 10;

    public static void main(String[] args) throws InterruptedException {
    
    
        Thread[] threads = new Thread[THREAD_COUNT];
        for (int i = 0; i < threads.length; i++) {
    
    
            threads[i] = new Thread(new Runnable() {
    
    
                @Override
                public void run() {
    
    
                    for (int j = 0; j < 1000; j++) {
    
    
                        a.incrementAndGet();
                    }
                }
            });
            threads[i].start();
        }
        // 保证线程执行完毕在输出,也可以停顿个3s
        Thread.sleep(500);
        System.out.println(a);
    }
}

结果总是输出10000,一切都归功于incrementAndGet();
在这里插入图片描述
也就是说,原子类的实现基本上离不开UnSafe类提供的CAS方法。其中,UnSafe的有关博客推荐大家阅读这篇文章:美团技术团队写的Unsafe应用解析

2.2.3 无同步方案(不重要)

首先,要保证线性安全,并不是一定要进行同步,两者是没有因果关系的。其中也有一部分代码是天生现行安全的。

可重入代码:可以在代码执行的任何时刻去中断它,转而去执行另外一段代码,而在控制权返回之后,原来的程序不会出现任何错误。

2.2.4 线程本地存储

如果一段代码中所需要的数据必须和其他代码共享,那么就可以使用ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象都有一个ThreadLocalMap对象(ThreadLocal的底层是个map)。其存储以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值。

三.锁和其优化

为了在线程之间更高效的共享数据,解决竞争问题。HotSpot又推出了各种锁的优化技术,如适应性自旋、锁消除、锁粗化、轻量级锁、偏向锁等。
接下来主要是对各种锁进行一个介绍和了解。

3.1 自旋锁和自适应自旋

使用锁时遇到的情况:
许多应用上,共享数据的锁定状态只会持续很短的一段时间为了这段时间去挂起或者恢复线程并不值得。 因此产生了自旋锁。

自旋锁认为如果持有锁的线程能够在很短的时间内释放资源,那么那些正在等待的线程就不用做内核态和用户态的转换而进入堵塞、挂起状态。只要等待一小段时间,就能在其他线程释放资源的瞬间可以立即获得锁。

优点:
减少CPU上下文的切换,因此对于占用锁资源时间短或者锁竞争不激烈的代码块性能会高一点。

缺点:
如果竞争激烈,那么可能导致长时间自旋,浪费CPU。

3.2 锁消除

锁消除是指虚拟机在编译器运行时,对一些代码要求同步,但是被检测到实际上不存在共享数据的竞争,那么对于这一类锁,会进行消除。
举一个形象的例子:

public String method(String s1, String s2, String s3) {
    
    
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();
}
// 其中append源码:
@Override
public synchronized StringBuffer append(String str) {
    
    
     toStringCache = null;
     super.append(str);
     return this;
 }

意思是append是经过synchronized加锁同步的,代码中method方法中的局部对象sb,就只在该方法内的作用域有效,不同线程同时调用method()方法时,都会创建不同的sb对象,因此此时的append操作若是使用同步操作,就是白白浪费的系统资源。因此这种情况下,我们可以通过编译器进行锁优化。

添加参数即可(java在Server模式下):

-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks

3.3 锁粗化

案例:

public void method(){
    
    
    synchronized(obj){
    
    
        //do some thing
    }
    // 做一些别的工作,只会花费很少的时间
    synchronized(obj){
    
    
        //do other thing
    }
}

上面的代码是有两块需要同步操作的,但在这两块需要同步操作的代码之间,需要做一些其它的工作,而这些工作只会花费很少的时间,那么我们就可以把这些工作代码放入锁内,将两个同步代码块合并成一个,以降低多次锁请求、同步、释放带来的系统性能消耗。

经过粗化后:

public void method(){
    
    
    synchronized(obj){
    
    
        //do some thing
        // 做一些别的工作,只会花费很少的时间
        //do other thing
    }
}

3.4 偏向锁

**偏向锁是指一段同步代码块一直被一个线程访问,那么该线程会自动获取锁,降低获取锁的代价。**其目标是在只有一个线程执行同步代码块的时候能够提高性能。

当一个线程访问同步代码块并获取锁的时候,会在Mark Word里面存储锁偏向的线程ID。在线程进入和退出同步块时候不再通过CAS操作来加锁和解锁,而是通过检测Mark Word里面是否存储着指向当前线程的偏向锁。 而引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行,因为轻量级锁的获取和释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS指令即可。

特点:
偏向锁只有遇到其他线程尝试竞争偏向锁的时候,持有偏向锁的线程才会释放锁,也就是说线程不会主动释放锁。

猜你喜欢

转载自blog.csdn.net/Zong_0915/article/details/110409827