Java虚拟机基础知识整理

Java虚拟机基础知识整理

JVM的调优主要是针对内存区

一、JVM的体系结构

下图是JVM的内存模型简图:

在这里插入图片描述

上图中还存在着一个本地方法接口

其中栈、本地方法栈以及程序计数器是不会存在垃圾回收

并且JVM的大部分调优都是针对堆内存和方法区

以下是完整的JVM架构图:

在这里插入图片描述

二、类加载器详解

以下是以Car类为例子来解释类加载器以及对象初始化过程

在这里插入图片描述

共有以下加载器类型:

  • 虚拟机自带的加载器
  • 启动类(根)加载器
  • 扩展类加载器
  • 应用程序(系统类)加载器

双亲委派机制

类加载器收到类加载的请求,会将这个请求向上委托给父类加载器加载,一直向上委托,直到启动类加载器。启动类加载器检查是否能够加载当前这个类,如果能够加载则直接使用当前类,否则抛出异常,通知其他的子加载器加载。直到能够加载为止。

三、沙箱安全机制

(一)沙箱安全发展历史

Java安全模型的核心就是Java沙箱(sandbox)。

什么是沙箱?沙箱是一个限制程序运行的环境:沙箱机制就是将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。

沙箱主要限制系统资源访问,那系统资源包括什么?主要包括CPU、内存、文件系统、网络。

不同级别的沙箱对这些资源访问的限制也可以不一样。

所有的ava程序运行都可以指定沙箱,可以定制安全策略。

在Java中将执行程序分成本地代码和远程代码两种,本地代码默认视为可信任的,而远程代码则被看作是不受信的。对于授信的本地代码,可以访问一切本地资源
而对于非授信的远程代码在早期的ava实现中,安全依赖于沙箱(Sandbox)机制。如下图是JDK1.0的安全模型:

在这里插入图片描述

但这样的设置给程序的功能扩展带来了障碍。
因此在Java1.1版本中,针对安全机制做了改进,增加了安全策略,允许用户指定代码对本地资源的访问权限,如下图所示JDK1.1安全模型:

在这里插入图片描述

在Java1.2版本中,再次改进了安全机制,增加了代码签名,不论是本地代码或是远程代码,都会按照用户的安全策略设定,由类加载器加载到虚拟机中权限不同的运行空间,来实现差异化的代码执行权限控制,如下图所示为JDK1.2的安全模型:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-I4FviNHl-1663060044418)(Java虚拟机知识.assets/JDK1.2沙箱.png)]在这里插入图片描述

当前最新的安全机制则引入了域(Domain)的概念,虚拟机会把所有代码加载到不同的系统域和应用域。**系统域部分专门负责与关键资源进行交互,而各个应用域部分则通过系统域的部分代理来对各种需要的资源进行访问。**虚拟机中不同的受保护域(Protected Domain),对应不一样的权限(Permission)。存在于不同域中的类文件就具有了当前域的全部权限,如下图所示最新的安全模型(JDK1.6)。

在这里插入图片描述

(二)沙箱基本组件

  • 字节码校验器(bytecode verifier):确保Java类文件遵循Java语言规范,这样可以帮助Java程序实现内存保护,但并不是所有的类文件都会经过字节码校验,比如核心类。

  • 类装载器(class loader):其中类装载器在3个方面对Java沙箱起作用

    • 它防止恶意代码去干涉善意代码
    • 它守护了被信任的类库边界
    • 它将代码归入保护域,确定了代码可以进行哪些操作。

    虚拟机为不同的类装载器载入的类提供不同的命名空间,命名空间由一系列唯一的名称组成,每一个被装载的类将有一个名字,这个命名空间由Java虚拟机为每一个类装载器维护的,它们互相之间甚至不可见。

    类装载器采用的机制是双亲委派机制

    1. 从最内层JVM自带类装载器开始加载,外层恶意同名类得不到加载从而无法使用。
    2. 由于严格通过包来区分访问域,外层恶意的类通过内置代码也无法获得权限访问到内层类,破坏代码就无法生效
  • 存取控制器(access controller):存取控制器可以控制核心API对操作系统的存取权限,而这个控制的策略设定,可以由用户指定。

  • 安全管理器(security manager):是核心API与操作系统之间的主要接口,实现权限控制,比存取控制器优先级高。

  • 安全软件包(security package):java.security下的类和扩展包下的类,允许用户为自己的应用增加新的安全特性,包括:

    • 安全提供者
    • 消息摘要
    • 数字签名
    • 加密
    • 鉴别

四、Native以及方法区和寄存器

(一)Native本地方法

凡是带了native关键字的,说明Java的作用范围达不到了,程序会去调用底层C语言的库。

凡是带了native关键字的,会进入本地方法栈,会调用本地方法接口,即JNI(本地方法接口,是为了扩展Java的使用,融合不同的编程语言为Java所用。)
即:Java在内存区域专门开辟了一块标记区域,Native Method Stack,登记Native方法,在最终执行的时候,加载本地方法库中的方法。

(二)PC寄存器

程序计数器:Program Counter Register

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

(三)方法区

Method Area方法区

方法区是被所有线程共享,所有字段和方法字节码以及一些特殊方法,如构造函数,接口代码也在此定义,简单说,所有定义的方法信息都保存在该区域。此区域属于共享区间。

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

五、栈(Stack)

(一)栈的基本概念

即栈内存,主管程序的运行,生命周期和线程同步,线程结束,栈内存释放。对栈来说,不存在垃圾回收问题。线程是私有的。

栈的组成:

  • 八大基本类型
  • 对象引用
  • 实例的方法

栈容易出现的错误是:StackOverFlowError

以下是栈与栈帧的示意图:

在这里插入图片描述

(二)栈帧各部分详解

在这里插入图片描述

  • 局部变量表:局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法表的Code属性的max_locals数据项中确定了该方法需要分配的最大局部变量表的容量。

    在方法执行时,虚拟机是使用局部变量表完成参数变量列表的传递过程
    如果是实例方法,那么局部变量表中的每0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数,其余参数则按照参数列表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域来分配其余的Slot。
    局部变量表中的Slot是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法,如果当前字节码PC计算器的值已经超出了某个变量的作用域,那么这个变量对应的Slot就可以交给其它变量使用。

    如果一个局部变量定义了但没有赋初始值是不能使用的。

  • 操作数栈:操作数栈也常被称为操作栈,它是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也是编译的时候被写入到方法表的Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。栈容量的单位为“字宽”,对于32位虚拟机来说,一个”字宽“占4个字节,对于64位虚拟机来说,一个”字宽“占8个字节。

    当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指向操作数栈中写入和提取值,也就是入栈与出栈操作。
    例如,在做算术运算的时候就是通过操作数栈来进行的,又或者调用其它方法的时候是通过操作数栈来行参数传递的。

    在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但是大多数虚拟机的实现里都会作一些优化处理,令两个栈帧出现一部分重叠。让下栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用返回时就可以共用一部分数据,而无须进行额外的参数复制传递了

  • 动态链接:**每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。**在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态链接。

  • 方法返回地址:当一个方法被执行后,有两种方式退出这个方法。

    第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法方式称为正常完成出口(Normal Method Invocation Completion)。

    另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用throw字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的调用者产生任何返回值的。

    无论采用何种方式退出,在方法退出之前,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。

    一般来说,方法正常退出时,调用者PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。

    而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。

    方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调用PC计数器的值以指向方法调用指令后面的一条指令等。

  • 附加信息:虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与高度相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息。

六、堆(Heap)

(一)堆的基本概念

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

类加载器读取类文件之后,一般会把类、方法、常量、变量、保存所有引用类型的真实对象放入到堆中。

堆内存中还可以细分为如下三个区域:

  • 新生代(伊甸园区+幸存区) Young/New ,比例一般是8:1:1
  • 老年代 Old
  • 永久区 Perm

以下是JVM堆内存的区域:
在这里插入图片描述

  • 伊甸园区:伊甸园区是对象创建的区域,但是伊甸园区如果满了的话会调用GC进行轻度垃圾回收,如果此时对象被引用就会幸存下来进入到幸存区,此时伊甸园区的内存清空,垃圾被回收。
  • 幸存区:轻度的GC尚未回收掉的内存会进入到幸存区,幸存区有两个,一个0区一个1区。幸存区+伊甸园区合称为新生区,一般而言Eden与幸存区的比例为8:1:1(但需要手动设置)

JVM内存的新生代比例s0:s1:eden=1:1:8有两个前提条件:

  1. 关闭内存分配策略自适应(默认开启),-XX:-UseAdaptiveSizePolicy
  2. 手动设置Eden区与Survivor区比例,-XX:SurvivorRatio=8
  • 老年区:幸存区内存满了之后就会进入老年区,老年区中的内存如果满了就会触发重度的GC回收,FullGC。(只要经过15次gc依旧存活的对象就会进入老年区)
  • 永久区(元空间):永久区是常驻内存的,用来存放JDK自身携带的Class对象和interface元数据,用于存储Java运行时环境。这个区域不存在垃圾回收,关闭JVM虚拟机就会释放这个区域的内存。

1、jdk1.6以前,叫做永久代 , 常量池在方法区
2、jdk1.7, 叫做永久代,常量池在堆中。提出了一个概念是去永久化。
3、jdk1.8,没有永久代,常量池在元空间。

元空间在逻辑上存在,在物理上不存在。

逃逸分析

就是分析对象动态作用域,当一个对象在方法中被定义后,它也可能会被外部方法所引用,例如作为调用参数传递到其他方法中。

-XX +DoEscapeAnalysis设置逃逸分析。

使用逃逸分析,编译器可以做以下优化:

  1. 同步省略,如果发现一个对象只能够从一个线程被访问到,那么对于这个对象的操作就可以不考虑同步。
  2. 将堆分配转为栈分配:如果一个对象在子程序中被分配,要使指向此对象的指针永远不会逃逸,对象可能是栈分配的候选而不是堆分配(实际上是标量替换)
  3. 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存中,而是存储在CPU的寄存器中。

在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有发布到其他线程。如果同步块所使用的锁对象被分析证实只能够被一个线程访问,就会优化成锁消除

(二)操作堆内存

  • 使用RunTime类获取虚拟机使用的最大内存以及JVM的总内存
  //返回虚拟机试图使用的最大内存
        long maxMemory = Runtime.getRuntime().maxMemory();
        //返回JVM的总内存
        long totalMemory = Runtime.getRuntime().totalMemory();

        System.out.println("maxMemory:"+maxMemory+"字节\t,"+maxMemory/(double)1024/1024+"MB");
        System.out.println("totalMemory:"+totalMemory+"字节\t,"+totalMemory/(double)1024/1024+"MB");

结果为:

maxMemory:1821376512字节	,1737.0MB
totalMemory:124780544字节	,119.0MB

可以看出maxMemory最大内存大约是总内存的四分之一,JVM的总内存为119MB

  • 我们可以通过修改相应的参数来改变对应的最大内存以及总内存

-Xms1024m -Xmx1024m -XX:+PrintGCDetails

前者表示初始化时分配的内存(默认内存的64分之1),后者表示允许分配的最大内存(默认内存的4分之1)

如果出现OOM(内存溢出错误),应该怎么办?

  1. 尝试扩大堆内存,并看结果如何
  2. 分析内存,查看哪些地方出现问题

(三)JVM常用参数选项

参数选项 含义
-Xms 初始堆大小。如:-Xms256m
-Xmx 最大堆大小。如:-Xmx512m
-Xmn 新生代大小。通常为 Xmx 的 1/3 或 1/4。新生代 = Eden + 2 个 Survivor 空间。实际可用空间为 = Eden + 1 个 Survivor,即 90%
-Xss JDK1.5+ 每个线程堆栈大小为 1M,一般来说如果栈不是很深的话, 1M 是绝对够用了的。
-XX:NewRatio 新生代与老年代的比例,如 –XX:NewRatio=2,则新生代占整个堆空间的1/3,老年代占2/3
-XX:SurvivorRatio 新生代中 Eden 与 Survivor 的比值。默认值为 8。即 Eden 占新生代空间的 8/10,另外两个 Survivor 各占 1/10
-XX:PermSize 永久代(方法区)的初始大小
-XX:MaxPermSize 永久代(方法区)的最大值
-XX:+PrintGCDetails GC 信息
-XX:+HeapDumpOnOutOfMemoryError 让虚拟机在发生内存溢出时 Dump 出当前的内存堆转储快照,以便分析用
-XX:MaxTenuringThreshold=xxx 通过这个参数可以设定进入老年代的时间

(四)Jprofiler内存快照分析工具

  1. 作用:
  • 分析Dump内存文件,快速定位内存泄漏
  • 获得堆中的数据
  • 获得大的对象
  1. 下载安装

在idea的插件市场中搜索JProfiler,install安装即可。

安装完成后显示:

在这里插入图片描述

同时在JProfiler的官网下载12版本,同时使用如下注册码进行注册安装。

L-J12-STALKER#5846458-y8bdm6q8gtr7b#228a

L-J12-STALKER#8338547-qywh5933xu2r3#a4a4

在终端中启动即可:

cd /opt/jprofiler12/bin
jprofiler
......
# 启动成功

与idea完成集成
在这里插入图片描述

  1. 使用定位错误

在对应的类上设置如下参数

-Xms1m-Xmx8m -XX:+HeapDumpOnxxxError

在出现错误的时候JProfiler插件可以自动生成对应的pro文件,该文件可以在JProfiler中打开完成分析。

七、GC:垃圾回收

(一)GC的基本概念

GC的作用区域本质上只有堆和方法区

JVM在进行GC垃圾回收的时候,并不是对伊甸园区、幸存区(from,to)与老年区一次性回收,大部分回收都是在伊甸园区

GC的分类:

  • 轻GC(普通的GC,Minor GC):此时如果新生的对象无法在 Eden 区创建(Eden 区无法容纳) 就会触发一次Young GC 此时会将 S0 区(幸存0区)与Eden 区的对象一起进行可达性分析,找出活跃的对象,将它复制到 S1 区并且将S0区域和 Eden 区的对象给清空,这样那些不可达的对象进行清除,并且将S0 区 和 S1区交换。
  • 重GC(全局GC,Major GC):发生在老年代的GC ,基本上发生了一次Major GC 就会发生一次 Minor GC。并且Major GC 的速度往往会比 Minor GC 慢 10 倍。

(二)GC常用算法

1. 引用计数法

引用计数算法是为每个对象一个计数器用来保存对象被引用的次数,如果该对象被其他对象引用,计数器加1,对该对象引用结束则计数器减1,当计数器为0时认为该对象已经没有任何引用,就会被回收。

但引用计数法会出现循环依赖问题,如果存在两个实例对象互相引用的话就会出现计数器一直为1的情况,GC就永远无法回收他们。

引用计数法图示:

String str=new String("gc");
str=null;

在这里插入图片描述

2. 复制算法

新生代主要使用的是复制算法

将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色(即幸存from区到幸存to区,并且to区始终是要保证是空的),最后完成垃圾回收。

把可达的对象,直接复制到另外一个区域中。复制完成后,A区就没有用了,里面的对象可以直接清除掉,新生代就用到了复制算法。

判断存活的对象方式只有两种:引用计数与可达性分析
在这里插入图片描述

需要注意的是新生代的对象如果经过15次gc仍旧没有被清除,那么它才会进入老年代。

但是可以通过设置参数来设置进入老年代的值,通过-XX:MaxTenuringThreshold,如果设置为0,则表示年轻代对象不经过幸存区,直接进入老年区,如果数值越大,表示年轻代对象会在Survivor区进行多次复制,增加了对象在年轻代的存活时间,增加在年轻代即被回收的概率。

优点:没有内存的碎片

缺点:浪费了内存空间,多了一 半的空间始终是空的,只有对象存活度低的时候适用。

3.标记清除算法

标记清除(Mark-Sweep)算法的整个过程就像其名称一样分为两步:标记(Mark)和 清除(Sweep)。

  • 标记过程:标记过程其实就是判定对象是否是可被回收对象,在Java中,最常用的是:**根可达性分析算法。**如下图所示:

在这里插入图片描述

标记哪种对象都可以,如果是标记可回收的,那么就清理可回收对象,标记不可回收的,也清理可回收对象。

  • 清除过程:直接释放掉可回收对象占用的堆空间即可。

缺点:

  • 空间问题:由于对象与对象之间在堆内存中分配的物理内存不是连续的,经过一次标记与清除之后,很大几率上会出现内存碎片
  • 时间问题:由于分为两个过程(标记、清除),当堆内可回收对象较多时,该算法需要进行大量的标记与清除,这里就产生一个问题,随着可回收对象的的增多,标记和清除的效率就会下降;再者由于空间不连续导致每次再次分配都要遍历空闲列表。
  • 在标记和清理阶段,都要遍历整个堆,SWT(Stop The World)时间长。

优点:不需要额外的空间,弥补了复制算法的缺陷。

4. 标记压缩算法

即在标记清除算法的基础上进行再优化(优化内存碎片)

在标记清除算法之后再次扫描,向一端移动存活的对象。

但是多了一个移动存活对象的成本。

总结:

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

(三)垃圾回收总结

  • 垃圾对象的判断:引用计数器,可达性分析(虚拟机栈-局部变量、方法区类属性和常量所引用的对象,本地方法栈所引用的都可以作为GCroot)
  • 回收策略:标记清除(效率差,存在内存碎片),复制(没有碎片,但是浪费时间),标记整理(没有碎片,但是需要移动对象)

八、JMM(Java内存模型)

(一)概述

JMM即Java内存模型,【JMM】(Java Memory Model的缩写)允许编译器和缓存以数据在处理器特定的缓存(或寄存器)和主存之间移动的次序拥有重要的特权,除非程序员使用了volatile或synchronized明确请求了某些可见性的保证。

Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

**JMM模型定义的内存分为工作内存和主内存,**工作内存是从主内存拷贝的副本,属于线程私有。当线程启动时,从主内存中拷贝副本到工作内存,执行相关指令操作,最后写回主内存。

如下图所示:

在这里插入图片描述

(二)特性

JMM三大特性,对于并发线程来说,也是常常容易出现问题的地方,因此JMM也可以说上主要是针对解决这三大核心问题的方法思路总结。

1. 原子性

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。例如两条线程对同一共享变量进行操作,不管线程如何运行,该共享变量的值是唯一,也就是说线程间是不互相干扰的。多线程存在安全问题的根本原因在于对同一个共享的资源进行了非原子性的操作。

需要注意的是:

对于32位的系统来说,对于long和double类型的数据(64位数据)读写并非是原子性的,但是对其他基本数据类型的读写是原子性的。对long和double类型读写不是原子性是因为机器字长的原因,每次的原子读写都是32位的,这样会导致一个线程在读取前32位数据之后,轮到另一个线程来读取恰好读取到该变量的后32位数据,这就造成了非原子性。

除此之外,计算机在执行程序的时候会进行一系列的优化操作,即:指令重排

为了提高性能,一般有3种重排的方式

在这里插入图片描述

  • 编译器优化重排:编译器在不改变线程语义的情况下,重新安排语句的执行顺序
  • 指令并行重排:由于现代处理器采用指令级并行技术来将多条指令重叠执行,在不存在数据依赖的前提下(即后一个指令的结果依赖前一个结果),处理器可以改变语句对应的机器指令的执行顺序。
  • 内存系统重排:由于现代处理器使用了多重缓存的架构设计,导致内存与缓存之间的数据同步出现了时间差,这样使得加载和存储看似乱序执行。

以上的重排是出于性能的优化,但是在多线程环境下会导致可见性问题,

2. 可见性

**可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。**对于串行程序(类似串联)是不存在可见性问题的,只有并发程序并且共享某些资源的时候才会出现可见性问题。

因为在多线程的环境下,所有的线程都是从主内存中拷贝一部分到各自的私有的工作内存中进行操作的,之后是直接写入主内存的,这时候容易出现工作内存与主内存的同步问题。并且指令重排也可能会影响到可见性,因为他们都会改变程序的指令执行顺序。

3. 有序性

所谓的有序性是指单线程下,代码的执行是按照顺序来执行,但是放在多线程中却不能总是满足按照顺序来执行,会出现乱序的现象。

(三)解决方案

1.语言层面解决方案

  • 对于原子性引发的安全问题:

可以使用JVM提供的原子类型数据,也可以使用Synchronized关键字或者Lock锁接口的方法来保证原子性。

  • 对于线程工作内存与主内存可见性问题:

可以使用加锁或者使用Volatile关键字,可以使一个线程修改后的变量对其他线程可见。

Volatile除了可以保证可见性之外,还可以防止指令重排(禁止指令重排底层是通过设置内存屏障来实现),但是其不保证原子性。

2.JMM内部解决方案

(1)as-if-serial

不管如何重排序,单线程的执行结果不可变,编译器,处理器等都必须遵循as-if-serial语义,即对存在数据依赖关系的不做重排序,对不存在数据依赖关系的进行重排序。这是对单线程程序的一种保护机制。

(2)happens-before

主要用于阐述操作之间的内存可见性。

  • 如果一个操作happens-berfore另一个操作,那么第一操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序在第二个操作之前。
  • 如果两个操作之间存在happens-berfore关系,并不一定要按照happens-before原则制定的顺序执行,如果重排序后的执行结果与按照happens-before关系来执行的结果一致,那么此次重排序并不非法。

需要遵循八个规则:(摘自《深入理解Java虚拟机》)

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

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_50824019/article/details/126837273
今日推荐