Java日志——JVM探究

JVM探究

  • 请你谈谈对jvm的理解?java8虚拟机和之前的变化更新
  • 什么是OOM,什么是栈溢出?怎么分析?
  • JVM的常用调优?
  • 内存快照如何抓取,怎么分析Dump文件?
  • 谈谈JVM中,类加载器的认识?

总结图:
Java日志——JVM探究_第1张图片

1、JVM的位置

Java日志——JVM探究_第2张图片

2、JVM的体系结构

Java日志——JVM探究_第3张图片

注意:java的底层是c++,java语言的开发目的是为了去除c++繁琐的机制,比如指针和内存管理。在很多jar包的源代码中,多次出现native关键字,这表示该方法在JVM中没法实现,而是调用了操作系统的本地方法接口。例如,Java的多线程的底层实现就是调用了本地c++的多线程接口

3、类加载器

作用:加载Class文件

Java日志——JVM探究_第4张图片

  1. 虚拟机自带的类加载器

  2. 根加载器(BootClassLoader)一般是在rt,jar

  3. 扩展类加载器(ExtClassLoader)一般在ext.jar

  4. 应用程序加载器(ApplicationClassLoader)

4、双亲委派机制

目的:安全

  1. 类加载器收到类加载的请求
  2. 将这个请求委托给父类加载器,层层委托,直到根加载器
  3. 启动加载器检查是否能加载当前这个类,如果能加载,就结束委派,使用当前加载器加载;否则抛出异常,通知子加载器加载
  4. 重复步骤3

5、沙箱安全机制(了解)

通过双亲委派机制,类的加载永远都是从 启动类加载器开始,依次下放,保证你所写的代码,不会污染Java自带的源代码,所以出现了双亲委派机制,保证了沙箱安全

Execution Engine 执行引擎负责解释命令,提交操作系统执行。

6、Native

凡是带了native关键字,说明java的作用范围到底了,需要调用底层C和C++的库。该方法执行时会进入本地方法栈,这是java在内存区域中专门开辟的一块标记区域:Native Method Stack,用于登记带有native的方法,并在最终执行的时候,通过JNI(java native interface)调用底层的库。

7、PC寄存器

记录了方法之间的调用和执行情况,类似班级的值日表,用来存储指向下一条指令的地址,也即将要执行的指令代码

每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计。

这块存储区域很小,他是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令。

如果执行的是一个Native方法,那这个计数器就是空的,因为native已经不属于Java的范畴了 。

用以完成分支、循环、跳转、异常处理、线程恢复等基础功能。不会发生内存溢出(OutOfMemory = OOM)错误

8、方法区

方法区:供各路线程运行时共享的内存区域,存储着类的结构信息,如构造函数,接口代码,所有字段和方法字节码,简单来说,所有定义的方法信息都被保存在这个区域

静态变量、常量、类信息(构造方法,接口定义)、运行时的常量池存在方法区中,而实例变量存在堆内存中,与方法区无关

static,final,Class,运行时常量池

方法区就是一个规范,在不同的虚拟机里实现是不一样的,最典型的就是 永久代(PermGen space) 和 元空间 (Metaspace) 永久代:JDK1.7

元空间:JDK1.8

方法区、元空间与永久代

在JVM规范中,方法区可以理解为一个逻辑区域,而方法区的具体实现是各个JVM自己的内容。比如在HotSpot中,JDK1.7之前都将方法区称为永久代,永久代的所在的区域在JVM内存区域中;在JDK1.8版本中,HotSpot将永久代替换为了元空间 ,元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过**元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。**理论上取决于32位/64位系统可虚拟的内存大小。可见也不是无限制的,需要配置参数。

为什么要在直接内存里拿出来一块内存作为元空间取代永久代呢?主要的说法有以下几个:

(1)类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。

(2)永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

但是两者都不存在方法区中,就像上面所讲,方法区存的是模板,而永久代和元空间是这套模板的实例,后面在堆中讨论

所以,实例变量存储在堆内存中,和方法区无关。

常量池,一般分为静态常量池、运行时常量池、字符串常量池和基本数据包装类常量池

静态常量池:即*.class文件中的常量池,在Class文件结构中,最头的4个字节存储魔数,用于确定一个文件是否能被JVM接受,接着4个字节用于存储版本号,前2个为次版本号,后2个主版本号,再接着是用于存放常量的常量池,由于常量的数量是不固定的,所以常量池的入口放置一个U2类型的数据(constant_pool_count)存储常量池容量计数值。这种常量池占用class文件绝大部分空间,主要用于存放两大类常量:字面量和符号引用量,字面量相当于Java语言层面常量的概念,如文本字符串、基础数据、声明为final的常值等;符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:类和接口的全限定名、字段名称描述符、方法名称描述符。类的加载过程中的链接部分的解析步骤就是把符号引用替换为直接引用,即把那些描述符(名字)替换为能直接定位到字段、方法的引用或句柄(地址)。

运行时常量池:虚拟机会将各个class文件中的常量池载入到运行时常量池中,即编译期间生成的字面量、符号引用,总之就是装载class文件。为什么它叫运行时常量池呢?因为这个常量池在运行时,里面的常量是可以增加的。如:“+”连接字符生成新字符后调用 intern()方法、生成基础数据的包装类型等等。

字符串常量池:字符串常量池可以理解为是分担了部分运行时常量池的工作。加载时,对于class文件的静态常量池,如果是字符串就会被装到字符串常量池中。

总结:class文件有常量池存放这个类的信息,占用了大多数空间。但是运行时所有加载进来的class文件的常量池的东西都要放到运行时常量池,这个运行时常量池还可以在运行时添加常量。字符串常量池、基本数据包装类等常量池则是分担了运行时常量池的工作。

在永久代移除后,字符串常量池也不再放在永久代了,但是也没有放到新的方法区—元空间里,而是留在了堆里(为了方便回收?)。运行时常量池当然是随着搬家到了元空间里,毕竟它是装类的重要信息的,有它的地方才称得上是方法区。
Java日志——JVM探究_第5张图片

测试

public class Test {
        public static void main(String[] args) {
            //字符串常量池,如果常量池没有“abc”,则创建;如果有,则返回常量池的引用
            String s1 = "abc";
            //堆内存常量池,如果常量池没有"abc",则创建两个对象,一个是在常量池创建“abc”,
            //另一个是在堆内存中创建一个String对象;否则,只创建一个String对象
            String s2 = new String("abc");
            System.out.println(s1 == s2);//false

            final String s3 = "hello";
            final String s4 = new String("hello");
            System.out.println(s3 == s4);//false

            String s5 = new String("a") + "bc";
            System.out.println(s1 == s5);//false

            String s6 = "a";
            String s7 = s6 + "bc";
            System.out.println(s1 == s7);//false

            String s8 = "a" + "bc";
            System.out.println(s8 == s1);//true
        }
}

9、栈(记住:栈管运行,堆管存储)

栈也叫栈内存,主管Java程序的运行,是在线程创建时创建,他的生命周期是跟随线程的生命周期,线程结束那么栈内存也就随之释放, 对于栈来说不存在垃圾回收问题 ,只要线程已结束该栈就over了,是线程私有的。8种基本类型的变量 + 对象的引用变量 + 实例方法都是在函数的占内存中分配。

比如说:程序从main方法最先进入,却最后退出,其原理就是在main主线程创建的时候,main()方法就被压入了主线程的栈区,也就是栈的底部,所以最后弹出。

  • 栈存储什么?

    ​ 栈帧中主要保存 3 类数据 : (何为栈帧:即Java中的方法,只是在jvm中叫做栈帧)

    • 本地变量 (Local Variables) : 入参和出参 以及方法内的变量;
    • 栈操作 (Operand Stack) : 记录出栈 和 入栈的操作;(可理解为pc寄存器的指针)
    • 栈帧数据 (Frame Data) : 包括类文件、方法等。
  • 栈的运行原理:

    栈中的数据都是以栈帧 (Stack Frame) 的格式存在,栈帧是一个内存区块,是一个有关方法和运行期数据的数据集, 当一个方法A被调用时就产生了栈帧 F1,并被压入到栈中,

    A方法又调用 B方法,于是产生栈帧F2 ,也被压入栈,

    B方法又调用 C方法, 于是产生栈帧F3,也被压入栈

    ……

    执行完毕后,先弹出F3栈帧,再弹出 F2栈帧,再弹出 F1栈帧 ……

    遵循 “先进后出” / “后进先出” 原则。

    每个方法执行的同时都会创建一个栈帧,用于存储局部变量表,操作数据栈,动态连接、方法出口等信息,每一个方法从调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中入栈到出栈的操作过程

    栈的大小和具体jvm的实现有关,通常在 256K ~ 756K 之间,约等于 1Mb左右。

Java日志——JVM探究_第6张图片

对象实例化的过程就是栈、堆、方法区交互的过程
Java日志——JVM探究_第7张图片

10、三种JVM

  • Sun公司 HotSpot Java HotSpot(TM) 64-Bit Server VM (build 25.261-b12, mixed mode)
  • Oracle公司 BEA JRockit
  • IBM J9 VM

11、堆

Heap,一个JVM只有一个堆内存,堆内存的大小是可以调节的。

类加载读取了类文件后,一般会把什么东西放在堆中?类,方法,常量,变量,保存所有引用类型的真实对象

堆内存细分为三个区域:

  • 新生代
  • 养老代
  • 永久代(现在叫元空间)

Java日志——JVM探究_第8张图片

GC垃圾回收,主要是在伊甸园区和养老区

假设内存满了,OOM(堆内存不够),排错方式:

  • 尝试扩大堆内存,看结果
  • 分析内存,看一下哪个地方出了问题

设置VM运行参数:-Xms200m -Xmx1024m -XX:+PrintGCDetails

public class Test2 {
    public static void main(String[] args) {
        long max = Runtime.getRuntime().maxMemory();
        long total = Runtime.getRuntime().totalMemory();
        System.out.println("JVM试图使用的最大运行内存:"+(max/((double)1024*1024))+"MB");
        System.out.println("JVM初始化总内存"+(total/((double)1024*1024))+"MB");
    }
}
运行结果:
JVM试图使用的最大运行内存:910.5MB
JVM初始化总内存192.0MB
Heap
 PSYoungGen      total 59904K, used 5181K [0x00000000eab00000, 0x00000000eed80000, 0x0000000100000000)
  eden space 51712K, 10% used [0x00000000eab00000,0x00000000eb00f428,0x00000000edd80000)
  from space 8192K, 0% used [0x00000000ee580000,0x00000000ee580000,0x00000000eed80000)
  to   space 8192K, 0% used [0x00000000edd80000,0x00000000edd80000,0x00000000ee580000)
 ParOldGen       total 136704K, used 0K [0x00000000c0000000, 0x00000000c8580000, 0x00000000eab00000)
  object space 136704K, 0% used [0x00000000c0000000,0x00000000c0000000,0x00000000c8580000)
 Metaspace       used 3233K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 354K, capacity 388K, committed 512K, reserved 1048576K

//59904 + 136704 = 192.0 MB 
//即JVM初始化总内存是伊甸园区和老年代的内存之和,所以准确来说,元空间逻辑上存在,但实际地址不在JVM内存中!

12、新生代、老年代

新生代(伊甸园区满了,会触发一次轻GC,活过来的对象,进入to区(此前是空的),同时也会将from区的对象转移给to区,然后这两个区交换名称。记住:from区和to区是逻辑上的概念,这两个区中空的叫to区,非空的叫from区

  • 类:诞生和成长的地方,甚至死亡
  • 伊甸园区:所有对象都是在伊甸园区new出来的
  • 幸存者区(0,1),也是逻辑上的from区和to区

老年代(当新生代的to区存放不下了from区和伊甸园存活下来的对象时,就会触发一次重GC,清理新生区和老年代。当一个对象经历了15次(默认是15次)GC后,还没有死,就进入老年代

Java日志——JVM探究_第9张图片

写个死循环来查看GC日志:

VM参数:-Xms4m -Xmx4m -XX:+PrintGCDetails

[GC (Allocation Failure) [PSYoungGen: 510K->488K(1024K)] 510K->488K(3584K), 0.0080675 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [PSYoungGen: 1000K->488K(1024K)] 1000K->584K(3584K), 0.0007194 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 995K->504K(1024K)] 1091K->672K(3584K), 0.0006422 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 994K->508K(1024K)] 1162K->784K(3584K), 0.0004698 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 922K->504K(1024K)] 1198K->880K(3584K), 0.0004351 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 822K->504K(1024K)] 1598K->1279K(3584K), 0.0003881 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) --[PSYoungGen: 956K->956K(1024K)] 3332K->3516K(3584K), 0.0006531 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 956K->0K(1024K)] [ParOldGen: 2559K->991K(2560K)] 3516K->991K(3584K), [Metaspace: 3175K->3175K(1056768K)], 0.0029637 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 430K->32K(1024K)] 2221K->1822K(3584K), 0.0002623 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 32K->32K(1024K)] 1822K->1822K(3584K), 0.0003204 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 32K->0K(1024K)] [ParOldGen: 1790K->1790K(2560K)] 1822K->1790K(3584K), [Metaspace: 3189K->3189K(1056768K)], 0.0039520 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 0K->0K(1024K)] 1790K->1790K(3584K), 0.0003933 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(1024K)] [ParOldGen: 1790K->1772K(2560K)] 1790K->1772K(3584K), [Metaspace: 3189K->3189K(1056768K)], 0.0035559 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 1024K, used 81K [0x00000000ffe80000, 0x0000000100000000, 0x0000000100000000)
  eden space 512K, 15% used [0x00000000ffe80000,0x00000000ffe94590,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
  to   space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
 ParOldGen       total 2560K, used 1772K [0x00000000ffc00000, 0x00000000ffe80000, 0x00000000ffe80000)
  object space 2560K, 69% used [0x00000000ffc00000,0x00000000ffdbb050,0x00000000ffe80000)
 Metaspace       used 3252K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 356K, capacity 388K, committed 512K, reserved 1048576K
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3332)
	at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
	at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:674)
	at java.lang.StringBuilder.append(StringBuilder.java:208)
	at Test3.main(Test3.java:7)

13、永久代

这个区域常驻内存的。用来存放JDK自身携带的Class对象。Interface元数据,存储的是Java运行时的一些环境或类信息,这个区域不存在垃圾回收!关闭VM虚拟机就会释放这个区域的内存

  • jdk1.6之前:永久代,常量池在方法区
  • jdk1.7:永久代,常量池在堆中
  • jdk1.8:永久代改为元空间,且不在JVM内存中,而在本地内存中

14、堆内存调优

当程序出现OOM错误,若是能知道是哪一行代码出错,就可以很快的解决!所以需要内存快照分析工具,如MAT,Jprofiler

MAT,Jprofiler作用:

  • 分析Dump文件,快速定位内存泄漏
  • 获得堆中数据
  • 获得大的对象
import java.util.ArrayList;
import java.util.List;
//-Xms1m -Xmx10m -XX:+HeapDumpOnOutOfMemoryError 将OOM错误Dump下来
public class Test4 {
    public static void main(String[] args) {
        List<Test4> list = new ArrayList<>();
        int count = 0;
        try{
            while(true){
                Test4 test4 = new Test4();
                list.add(test4);
                count++;
            }
        }catch (OutOfMemoryError e){
            System.out.println(count);
            e.printStackTrace();
        }
    }
}

Java日志——JVM探究_第10张图片

15、GC(垃圾回收机制)

JVM在进行GC的时候,并不是对这三个区域统一回收,大部分都是新生代

GC的两种类:轻GC,重GC(全局GC)

  • 常用算法

    标记清除法,标记压缩法,复制算法,引用计数算法

复制算法

Java日志——JVM探究_第11张图片

  • 优点:没有内存的碎片
  • 缺点:浪费了内存空间—多了一半的空间永远是空to
  • 复制算法最佳食用场景:对象存活度较低,比如新生区

标记清除算法

Java日志——JVM探究_第12张图片

  • 优点:不需要额外的空间
  • 缺点:两次扫描,时间成本高,会产生内存碎片

标记清除压缩算法

优化标记清除算法:
Java日志——JVM探究_第13张图片

  • 优点:没有内存碎片
  • 缺点:多了一次扫描,时间成本更高

总结:

  • 内存效率:复制算法 > 标记清除算法 > 标记清除压缩算法(时间复杂度)
  • 内存整齐度:复制算法 = 标记清除压缩算法 > 标记清除算法
  • 内存利用率:标记清除压缩算法 = 标记清除算法 > 复制算法(空间复杂度)

对于GC算法,没有最好的算法,只有最合适的算法—GC分代算法

年轻代:

  • 存活率低
  • 复制算法

老年代:

  • 区域大:存活率
  • 标记清除(内存碎片不是太多)+ 标记压缩混合算法

16、JMM:Java Memory Model

JMM理解:

缓存一致性协议,用于定义数据读写的规则

JMM定义了JVM在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

内存划分:

Java日志——JVM探究_第14张图片

内存交互操作:

内存交互操作有8种,虚拟机实现必须保证每一个操作都是原子的,不可在分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许例外)

Java日志——JVM探究_第15张图片

    • lock (锁定):作用于主内存的变量,把一个变量标识为线程独占状态
    • unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
    • read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
    • load (载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
    • use (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
    • assign (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
    • store (存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
    • write  (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

JMM对这八种指令的使用,制定了如下规则:

    • 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write
    • 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
    • 不允许一个线程将没有assign的数据从工作内存同步回主内存
    • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作
    • 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
    • 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
    • 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
    • 对一个变量进行unlock操作之前,必须把此变量同步回主内存

JMM对这八种操作规则和对volatile的一些特殊规则就能确定哪里操作是线程安全,哪些操作是线程不安全的了。但是这些规则实在复杂,很难在实践中直接分析。所以一般我们也不会通过上述规则进行分析。更多的时候,使用java的happen-before规则来进行分析。

内存模型的问题:

  • 可见性问题:(可见性)

    工作线程操作共享变量的时候,是以拷贝的方式将主内存的共享变量拷贝到自己的工作内存。对于其他线程来说,该线程对共享变量的变更是不可见的,因为这个更改还没有flush到主存中:要解决共享对象可见性这个问题,我们可以使用volatile关键字。volatile关键字要求被修改之后的变量要求立即更新到主内存,每次使用前从主内存处进行读取。因此volatile可以保证可见性。除了volatile以外,synchronized和final也能实现可见性。synchronized保证unlock之前必须先把变量刷新回主内存。final修饰的字段在构造器中一旦完成初始化,并且构造器没有this逸出,那么其他线程就能看到final字段的值。

  • 并发问题:(原子性)

    多个工作线程操作主内存的共享变量,这个操作是并行的,比如说A、B两个线程对共享变量a都进行了加1操作,因为操作时并行的,所以不管是A还是B将结果flush到主存中,变量a都只会加1。这个问题就是线程的并发问题,可以用synchronized关键字或者对线程进行lock操作,来解决线程并发问题

  • 有序性问题:(有序性)

    在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。如果在线程内部观察,会发现当前线程的一切操作都是有序的。如果在线程的外部来观察的话,会发现线程的所有操作都是无序的。因为JMM的工作内存和主内存之间存在延迟,而且java会对一些指令进行重新排序。

    Java里面,可以通过volatile关键字来保证一定的“有序性”。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

    另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before 原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。

    Happens-before原则:(先行发生规则)

    • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
    • 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作
    • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
    • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
    • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作
    • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
    • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行
    • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

    大概意思就是当A操作先行发生于B操作,则在发生B操作的时候,操作A产生的影响能被B观察到,“影响”包括修改了内存中的共享变量的值、发送了消息、调用了方法等

扩展:volatile是否具有原子性?

volatile是不具备原子性的

看下面一个例子:

public class Test {
    public volatile int inc = 0;
     
    public void increase() {
        inc++;
    }
     
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
         
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

大家想一下这段程序的输出结果是多少?也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。

可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。

这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。

在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

假如某个时刻变量inc的值为10,

线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;

然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。

然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。

那么两个线程分别进行了一次自增操作后,inc只增加了1。

解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则,但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。

然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。

然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。

那么两个线程分别进行了一次自增操作后,inc只增加了1。

解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则,但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。

根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。

你可能感兴趣的:(java学习之路,jvm,java,多线程)