Loading

JVM初探

JVM初探在这里插入图片描述

JVM的位置

image-20210131203128245

JVM体系结构

  1. 寄存器:最快的存储区, 由编译器根据需求进行分配,我们在程序中无法控制.

  2. 堆:存放所有new出来的对象。

  3. 静态域:存放静态成员(static定义的)

  4. 常量池:存放字符串常量和基本类型常量(public static final)。

  5. 非RAM存储:硬盘等永久存储空间

​ 这里我们主要关心栈,堆和常量池,对于栈和常量池中的对象可以共享,对于堆中的对象不可以共享。栈中的数据大小和生命周期是可以确定的,当没有引用指向数据时,这个数据就会消失。堆中的对象的由垃圾回收器负责回收,因此大小和生命周期不需要确定,具有很大的灵活性。
对于字符串:其对象的引用都是存储在栈中的,如果是编译期已经创建好(直接用双引号定义的)的就存储在常量池中,如果是运行期(new出来的)才能确定的就存储在堆中。对于equals相等的字符串,在常量池中永远只有一份,在堆中有多份。

image-20210201133332797

类加载器

参考博客园:https://www.cnblogs.com/lanxuezaipiao/p/4138511.html

image-20210201163237637

预定义类加载器和双亲委派机制

  1. JVM预定义的三种类型类加载器:

    • 启动(Bootstrap)类加载器:是用本地代码实现的类装入器,它负责将 <Java_Runtime_Home>/lib下面的类库加载到内存中(比如rt.jar)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。

    • 标准扩展(Extension)类加载器:是由 Sun 的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将< Java_Runtime_Home >/lib/ext或者由系统变量 java.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。

    • 系统(System)类加载器:是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。它负责将系统类路径(CLASSPATH)中指定的类库加载到内存中。开发者可以直接使用系统类加载器。

    • 用户自定义(CustomClassLoader)加载器:java编写,用户自定义的类加载器,可加载指定路径的class文件

  2. 双亲委派机制描述:
    某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

几点思考

  1. Java虚拟机的第一个类加载器是Bootstrap,这个加载器很特殊,它不是Java类,因此它不需要被别人加载,它嵌套在Java虚拟机内核里面,也就是JVM启动的时候Bootstrap就已经启动,它是用C++写的二进制代码(不是字节码),它可以去加载别的类。

    这也是我们在测试时为什么发现System.class.getClassLoader()结果为null的原因,这并不表示System这个类没有类加载器,而是它的加载器比较特殊,是BootstrapClassLoader,由于它不是Java类,因此获得它的引用肯定返回null。

  2. 委托机制具体含义:
    当Java虚拟机要加载一个类时,到底派出哪个类加载器去加载呢?

    • 首先当前线程的类加载器去加载线程中的第一个类(假设为类A)。
      注:当前线程的类加载器可以通过Thread类的getContextClassLoader()获得,也可以通过setContextClassLoader()自己设置类加载器。

    • 如果类A中引用了类B,Java虚拟机将使用加载类A的类加载器去加载类B。

    • 还可以直接调用ClassLoader.loadClass()方法来指定某个类加载器去加载某个类。

    • protected Class<?> loadClass(String name, boolean resolve)
                  throws ClassNotFoundException
          {
              synchronized (getClassLoadingLock(name)) {
                  // 首先检查这个classsh是否已经加载过了
                  Class<?> c = findLoadedClass(name);
                  if (c == null) {
                      long t0 = System.nanoTime();
                      try {
                          // c==null表示没有加载,如果有父类的加载器则让父类加载器加载
                          if (parent != null) {
                              c = parent.loadClass(name, false);
                          } else {
                              //如果父类的加载器为空 则说明递归到bootStrapClassloader了
                              //bootStrapClassloader比较特殊无法通过get获取
                              c = findBootstrapClassOrNull(name);
                          }
                      } catch (ClassNotFoundException e) {}
                      if (c == null) {
                          //如果bootstrapClassLoader 仍然没有加载过,则递归回来,尝试自己去加载class
                          long t1 = System.nanoTime();
                          c = findClass(name);
                          sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                          sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                          sun.misc.PerfCounter.getFindClasses().increment();
                      }
                  }
                  if (resolve) {
                      resolveClass(c);
                  }
                  return c;
              }
          }
      
    • img

  3. 委托机制的意义 — 防止内存中出现多份同样的字节码
    比如两个类A和类B都要加载System类:

    • 如果不用委托而是自己加载自己的,那么类A就会加载一份System字节码,然后类B又会加载一份System字节码,这样内存中就出现了两份System字节码。
    • 如果使用委托机制,会递归的向父类查找,也就是首选用Bootstrap尝试加载,如果找不到再向下。这里的System就能在Bootstrap中找到然后加载,如果此时类B也要加载System,也从Bootstrap开始,此时Bootstrap发现已经加载过了System那么直接返回内存中的System即可而不需要重新加载,这样内存中就只有一份System的字节码了。

脑图总结

img

沙箱安全机制

​ Java安全模型的核心就是Java沙箱。沙箱机制就是讲Java代码限定在虚拟机JVM特定的运行范围中,并且严格限制代码对本地资源的访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。

​ 沙箱主要限制系统资源访问,例如:CPU、内存、文件系统、网络。不同级别的啥想对这些资源访问的限制也可以不一样

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

在这里插入图片描述

​ 通俗来说就是虚拟机把代码加载到拥有不同权限的域里,然后代码就拥有了该域的所有权限。这样就能控制不同代码拥有不同调用操作系统和本地资源的权限。

组成沙箱的基本组件:

1、字节码校验器(bytecode verifier):确保Java类文件遵循Java语言规范。可以帮助Java程序实现内存保护 。核心类不经过字节码校验。
2、类装载器:其中类装载器在3个方面对Java沙箱起作用

  • 防止恶意代码干涉善意代码(双亲委派机制)
  • 守护被信任的类库边界
  • 它将代码归入保护域,确定了代码可以进行哪些操作

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

  1. 从最内层JVM自带的类加载器开始加载,外层恶意同名类得不到加载从而无法调用

  2. 由于严格通过包来区分了访问域,外层恶意的类通过内置代码也无法获得权限访问到内部类,破坏代码就自然无法生效

  3. 存取控制器(access controller):存取控制器可以控制核心API对操作系统的存取权限,而这个控制的策略设定可以由用户指定。

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

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

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

Native方法区

  • native :凡是带了native关键字的,说明java的作用范围达不到了,会去调用底层c语言的库!
  • 会进入本地方法栈
  • 调用本地方法本地接口 JNI (Java Native Interface)
  • JNI作用:开拓Java的使用,融合不同的编程语言为Java所用!最初: C、C++
  • Java诞生的时候C、C++横行,想要立足,必须要有调用C、C++的程序
  • 它在内存区域中专门开辟了一块标记区域: Native Method Stack,登记native方法
  • 在最终执行的时候,加载本地方法库中的方法通过JNI
  • 例如:Java程序驱动打印机,管理系统,掌握即可,在企业级应用比较少
  • private native void start0();
  • 调用其他接口:Socket. . WebService~. .http~

程序计数器

作用:

​ PC 寄存器用来存储指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。

特点:

  • 是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域。
  • 在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
  • 任何时间一个线程都只有一个方法在执行,就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的 JVM 指令地址。或者,如果执行的是 native 方法,则是未指定值(null)。
  • 是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 字节码指示器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
  • 是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

使用 PC 寄存器存储字节码指令有什么用?为什么使用 PC 寄存器记录当前线程的执行地址呢?

因为 CPU 需要不断切换各个线程,这时候切换回来后,就得知道接着从哪里开始继续执行。

JVM 的字节码计时器就需要通过改变 PC 寄存器的值来明确下一条应该执行什么样的字节码。

PC 寄存器为什么会被设定为线程私有?

所谓多线程在一个特定的时间段内只会指定其中某一个线程的方法,CPU 不停做任务切换,这样必然导致经常中断或恢复,如何保证分毫不差呢?为了能够准确的记录各个线程正在执行的当前字节码指令地址,最好的办法就是为每一个线程单独分配一个 PC 寄存器,这样一来各个线程之间便可以独立计算,从而不会出现相互干扰的情况。

由于 CPU 时间片轮限制,众多线程在并发执行的过程中,任何一个确定的时刻,一个处理器或者多核处理器中的 一个内核,只会执行某个线程中的一条指令。

这样必然导致经常中断或恢复,如何保证分毫不差呢?每个线程创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。

内存模型

Java堆内存又溢出了!教你一招必杀技

  1. JVM内存划分为堆内存和非堆内存,堆内存分为年轻代(Young Generation)、老年代(Old Generation),非堆内存就一个永久代(Permanent Generation)。
  2. 年轻代又分为Eden和Survivor区。Survivor区由FromSpace和ToSpace组成。Eden区占大容量,Survivor两个区占小容量,默认比例是8:1:1。
  3. 堆内存用途:存放的是对象,垃圾收集器就是收集这些对象,然后根据GC算法回收。
  4. 非堆内存用途:永久代,也称为方法区,存储程序运行时长期存活的对象,比如类的元数据、方法、常量、属性等。

在JDK1.8版本废弃了永久代,替代的是元空间(MetaSpace),元空间与永久代上类似,都是方法区的实现,他们最大区别是:元空间并不在JVM中,而是使用本地内存。
元空间有注意有两个参数:

  • MetaspaceSize :初始化元空间大小,控制发生GC阈值
  • MaxMetaspaceSize : 限制元空间大小上限,防止异常占用过多物理内存

为什么移除永久代?

移除永久代原因:为融合HotSpot JVM与JRockit VM(新JVM技术)而做出的改变,因为JRockit没有永久代。
有了元空间就不再会出现永久代OOM问题了!

分代概念

新生成的对象首先放到年轻代Eden区,当Eden空间满了,触发Minor GC,存活下来的对象移动到Survivor0区,Survivor0区满后触发执行Minor GC,Survivor0区存活对象移动到Suvivor1区,这样保证了一段时间内总有一个survivor区为空。经过多次Minor GC仍然存活的对象移动到老年代。
老年代存储长期存活的对象,占满时会触发Major GC=Full GC,GC期间会停止所有线程等待GC完成,所以对响应要求高的应用尽量减少发生Major GC,避免响应超时。
Minor GC : 清理年轻代
Major GC : 清理老年代
Full GC : 清理整个堆空间,包括年轻代和永久代
所有GC都会停止应用所有线程。

为什么分代?

将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及GC频率。针对分类进行不同的垃圾回收算法,对算法扬长避短。

为什么survivor分为两块相等大小的幸存空间?

主要为了解决碎片化。如果内存碎片化严重,也就是两个对象占用不连续的内存,已有的连续内存不够新对象存放,就会触发GC。

JVM堆内存常用参数

参数 描述
-Xms 堆内存初始大小,单位m、g
-Xmx(MaxHeapSize) 堆内存最大允许大小,一般不要大于物理内存的80%
-XX:PermSize 非堆内存初始大小,一般应用设置初始化200m,最大1024m就够了
-XX:MaxPermSize 非堆内存最大允许大小
-XX:NewSize(-Xns) 年轻代内存初始大小
-XX:MaxNewSize(-Xmn) 年轻代内存最大允许大小,也可以缩写
-XX:SurvivorRatio=8 年轻代中Eden区与Survivor区的容量比例值,默认为8,即8:1
-Xss 堆栈内存大小

堆内存诊断

  1. jps 工具

    • 查看当前系统运行了哪些java进程
  2. jmap工具

  • 查看堆内存占用情况 jmap -heap 进程id
  1. jconsole 工具

    • 图形界面监控,可以连续监控
  2. jvisualvm 工具(当gc回收之后发现内存占用仍然很大,可以通过该命令点击堆dump生成此时刻堆内存占用情况信息)

    image-20210203155604142

方法区

官方文档:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html

image-20210203163002122

拿HotSpot 虚拟机来说,在 JDK1.7的时候,方法区被称作为永久代, 从JDK1.8开始,Metaspace (元空间)也就是我们所谓的方法区!

方法区(Method Area)与Java堆一样,都是各个线程共享的,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。

Java虚拟机规范中是这样定义方法区的:

它存储了每个类的结构信息,例如运行时常量池、字段、方法数据、构造函数和普通方法的字节码内容,还包括一些在类、实例、接口初始化时用到的特殊方法。

JDK1.8 之前的方法区

就以HotSpot 虚拟机来说,在 JDK1.8 之前,方法区也被称作为永久代,这个方法区会发生我们常见的 java.lang.OutOfMemoryError: PermGen space 异常,注意是永久代异常信息,我们也可以通过启动参数来控制方法区的大小:

  • -XX:PermSize 设置方法区最小空间

  • -XX:MaxPermSize 设置方法区最大空间

在JDK7之前的HotSpot虚拟机中,纳入字符串常量池的字符串被存储在永久代中,因此导致了一系列的性能问题和内存溢出错误。特别突出的例子就是Stringintern()方法

JDK1.8 之后的方法区

JDK8之后就没有永久代这一说法变成叫做元空间(meta space),而且将老年代与元空间剥离。元空间放置于本地的内存中,因此元空间的最大空间就是系统的内存空间了,从而不会再出现像永久代的内存溢出错误了,也不会出现泄漏的数据移到交换区这样的事情。用户可以为元空间设置一个可用空间最大值,不设置默认根据类的元数据大小动态增加元空间的容量。对于一个 64 位的服务器端 JVM 来说,其默认的–XX:MetaspaceSize 值为 21MB。也就是说默认的元空间大小是21MB

只要类加载器还存活,其加载的类的元数据也是存活的,不会被回收掉!也就是同生共死

虚拟机栈

​ Java栈也称作虚拟机栈(Java Vitual Machine Stack)该区域也是线程私有的,它的生命周期也与线程相同。也就是我们常常所说的栈,跟C语言的数据段中的栈类似。事实上,Java栈是Java方法执行的内存模型。为什么这么说呢?下面就来解释一下其中的原因。

  Java栈中存放的是一个个的栈帧每个栈帧对应一个被调用的方法,对应着每次方法调用时所占的内存。在栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向当前方法所属的类的运行时常量池(运行时常量池的概念在方法区部分会谈到)的引用(Reference to runtime constant pool)方法返回地址(Return Address)和一些额外的附加信息。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。因此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。讲到这里,大家就应该会明白为什么 在 使用 递归方法的时候容易导致栈内存溢出的现象了以及为什么栈区的空间不用程序员去管理了(当然在Java中,程序员基本不用关心内存分配和释放的事情,因为Java有自己的垃圾回收机制),这部分空间的分配和释放都是由系统自动实施的。对于所有的程序设计语言来说,栈这部分空间对程序员来说是不透明的。

下图表示了一个Java栈的模型:

img

  • 局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,其中存放的数据的类型是编译期可知的各种八大基本数据类型、对象引用(reference)和returnAddress类型(它指向了一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,即在Java程序被编译成Class文件时,就确定了所需分配的最大局部变量表的容量。当进入一个方法时,这个方法需要在栈中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小
    局部变量表的容量以变量槽(Slot)为最小单位。在虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小(允许其随着处理器、操作系统或虚拟机的不同而发生变化),一个Slot可以存放一个32位以内的数据类型:boolean、byte、char、short、int、float、reference和returnAddresss。reference是对象的引用类型,returnAddress是为字节指令服务的,它执行了一条字节码指令的地址。对于64位的数据类型(long和double),虚拟机会以高位在前的方式为其分配两个连续的Slot空间
    虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始到局部变量表最大的Slot数量,对于32位数据类型的变量,索引n代表第n个Slot,对于64位的,索引n代表第n和第n+1两个Slot。
    在方法执行时,虚拟机是使用局部变量表来完成参数值到参数变量列表的传递过程的,如果是实例方法(非static),则局部变量表中的第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
    局部变量表中的Slot是可重用的,方法体中定义的变量,作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那么这个变量对应的Slot就可以交给其他变量使用。这样的设计不仅仅是为了节省空间,在某些情况下Slot的复用会直接影响到系统的而垃圾收集行为。
  • 操作数栈,栈最典型的一个应用就是用来对表达式求值。想想一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根到底就是进行计算的过程。因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的。 操作数栈又常被称为操作栈,操作数栈的最大深度也是在编译的时候就确定了。32位数据类型所占的栈容量为1M,64为数据类型所占的栈容量为2M。当一个方法开始执行时,它的操作栈是空的,在方法的执行过程中,会有各种字节码指令(比如:加操作、赋值元算等)向操作栈中写入和提取内容,也就是入栈和出栈操作。
  • 指向运行时常量池的引用(动态连接),因为在方法执行的过程中有可能需要用到类中的常量,所以必须要有一个引用指向运行时常量。每个栈帧都包含一个指向运行时常量池(在方法区中,后面介绍)中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用,一部分会在类加载阶段或第一次使用的时候转化为直接引用(如final、static域等),称为静态解析,另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
  • 方法返回地址,当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。

问题分析

1、垃圾回收是否会设计栈内存?

​ 栈中不会出现垃圾回收,因为栈中存入的是一个个的栈帧,每一个栈帧对应一个方法,当方法调用完毕后,栈帧就会弹出虚拟机栈,并释放内存,所以不会出现垃圾回收。

2、栈内存分配越大越好吗?

​ 不是的,每个线程的栈内存分配的空间越大,但栈的物理空间是固定的,栈内存空间越大,那么所允许允许的线程数就越少。

3、方法内的局部变量是否是线程安全的?

  • 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的,每一个线程允许时都会创建一个局部变量,互不影响

  • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

栈内存溢出

  • 栈帧过多导致栈内存溢出

    可以设置栈的大小,-Xss1m

  • 栈帧过大导致栈内存溢出

线程诊断_cpu占用高

定位到程序中cpu占用过高的线程

  • 用top命令,查看服务器中哪一个进程对cpu的占用过高
  • ps H -eo pid,tid,%cpu | grep 进程id (ps命令,列出某个进程中的所有线程)
  • jstack 进程id
    • 根据线程id(十六进制的进程id) 找到有问题的线程(nid显示),进一步丁谓到问题代码的源码行号

程序运行很长时间没有结果

  • jstack 进程id检查程序运行时间很长的愿意,可能发生思索

Class文件常量池

二进制字节码

包括:类基本信息,常量池,类方法定义,包含了虚拟机指令

javap -v 查看.class文件的字节码

举例

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("hello world");
    }
}

通过javap -v helloworld.class查看其二进制的字节码

D:\IdeaProjects\utils\jsoup\src\test\java>javap -v HelloWorld.class
Classfile /D:/IdeaProjects/utils/jsoup/src/test/java/HelloWorld.class
  Last modified 2021-2-3; size 425 bytes	// 最后的修改时间
  MD5 checksum 40d591deaae550c726339ad2cf3ecc90	// md5签名
  Compiled from "HelloWorld.java"	// 来自编译的哪个java文件
public class HelloWorld				// 类的信息
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:			// 常量池
   #1 = Methodref          #6.#15         // java/lang/Object."<init>":()V
   #2 = Fieldref           #16.#17        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #18            // hello world
   #4 = Methodref          #19.#20        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #21            // HelloWorld
   #6 = Class              #22            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               SourceFile
  #14 = Utf8               HelloWorld.java
  #15 = NameAndType        #7:#8          // "<init>":()V
  #16 = Class              #23            // java/lang/System
  #17 = NameAndType        #24:#25        // out:Ljava/io/PrintStream;
  #18 = Utf8               hello world
  #19 = Class              #26            // java/io/PrintStream
  #20 = NameAndType        #27:#28        // println:(Ljava/lang/String;)V
  #21 = Utf8               HelloWorld
  #22 = Utf8               java/lang/Object
  #23 = Utf8               java/lang/System
  #24 = Utf8               out
  #25 = Utf8               Ljava/io/PrintStream;
  #26 = Utf8               java/io/PrintStream
  #27 = Utf8               println
  #28 = Utf8               (Ljava/lang/String;)V
{
  public HelloWorld();		// 默认的构造方法
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 5: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1	//方法内部开始执行,比如getstatic #2表示去常量池中的第#2寻找相应的信息
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String hello world
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 7: 0
        line 8: 8
}
SourceFile: "HelloWorld.java"

class JavaBean{
    private int value = 1;
    public String s = "abc";
    public final static int f = 0x101;

    public void setValue(int v){
        final int temp = 3;
        this.value = temp + v;
    }

    public int getValue(){
        return value;
    }
}
class JavaBasicKnowledge.JavaBean
  minor version: 0
  major version: 52
  flags: ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#29         // java/lang/Object."<init>":()V
   #2 = Fieldref           #5.#30         // JavaBasicKnowledge/JavaBean.value:I
   #3 = String             #31            // abc
   #4 = Fieldref           #5.#32         // JavaBasicKnowledge/JavaBean.s:Ljava/lang/String;
   #5 = Class              #33            // JavaBasicKnowledge/JavaBean
   #6 = Class              #34            // java/lang/Object
   #7 = Utf8               value
   #8 = Utf8               I
   #9 = Utf8               s
  #10 = Utf8               Ljava/lang/String;
  #11 = Utf8               f
  #12 = Utf8               ConstantValue
  #13 = Integer            257
  #14 = Utf8               <init>
  #15 = Utf8               ()V
  #16 = Utf8               Code
  #17 = Utf8               LineNumberTable
  #18 = Utf8               LocalVariableTable
  #19 = Utf8               this
  #20 = Utf8               LJavaBasicKnowledge/JavaBean;
  #21 = Utf8               setValue
  #22 = Utf8               (I)V
  #23 = Utf8               v
  #24 = Utf8               temp
  #25 = Utf8               getValue
  #26 = Utf8               ()I
  #27 = Utf8               SourceFile
  #28 = Utf8               StringConstantPool.java
  #29 = NameAndType        #14:#15        // "<init>":()V
  #30 = NameAndType        #7:#8          // value:I
  #31 = Utf8               abc
  #32 = NameAndType        #9:#10         // s:Ljava/lang/String;
  #33 = Utf8               JavaBasicKnowledge/JavaBean
  #34 = Utf8               java/lang/Object

可以看到这个命令之后我们得到了该class文件的版本号、常量池、已经编译后的字节码。既然是常量池,那么其中存放的肯定是常量,那么什么是“常量”呢? class文件常量池主要存放两大常量:字面量和符号引用

  1. 字面量: 字面量接近java语言层面的常量概念,主要包括:
  • 文本字符串,也就是我们经常申明的: public String s = "abc";中的"abc"
 #9 = Utf8               s
 #3 = String             #31            // abc
 #31 = Utf8              abc
  • 用final修饰的成员变量,包括静态变量、实例变量和局部变量
#11 = Utf8               f
#12 = Utf8               ConstantValue
#13 = Integer            257

这里需要说明的一点,上面说的存在于常量池的字面量,指的是数据的值,也就是abc和0x101(257),通过上面对常量池的观察可知这两个字面量是确实存在于常量池的。

而对于基本类型数据(甚至是方法中的局部变量),也就是上面的private int value = 1;常量池中只保留了他的的字段描述符I和字段的名称value,他们的字面量不会存在于常量池。

#2 = Fieldref           #5.#30         // JavaBasicKnowledge/JavaBean.value:I

2) 符号引用 符号引用主要设涉及编译原理方面的概念,包括下面三类常量:

  • 类和接口的全限定名,也就是java/lang/String;这样,将类名中原来的"."替换为"/"得到的,主要用于在运行时解析得到类的直接引用,像上面

    #5 = Class              #33            // JavaBasicKnowledge/JavaBean
     #33 = Utf8               JavaBasicKnowledge/JavaBean
    
  • 字段的名称和描述符,字段也就是类或者接口中声明的变量,包括类级别变量和实例级的变量

    #4 = Fieldref           #5.#32         // JavaBasicKnowledge/JavaBean.value:I
    #5 = Class              #33            //JavaBasicKnowledge/JavaBean
    #32 = NameAndType       #7:#8          // value:I
    
    #7 = Utf8               value
    #8 = Utf8               I
    
    //这两个是局部变量,值保留字段名称
    #23 = Utf8               v
    #24 = Utf8               temp
    

可以看到,对于方法中的局部变量名,class文件的常量池仅仅保存字段名。

  • 方法中的名称和描述符,也即参数类型+返回值

    #21 = Utf8               setValue
    #22 = Utf8               (I)V
    
    #25 = Utf8               getValue
    #26 = Utf8               ()I
    

运行时常量池

  • 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量 等信息
  • 运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量 池,并把里面的符号地址变为真实地址

运行时常量池是方法区的一部分,所以也是全局贡献的,我们知道,jvm在执行某个类的时候,必须经过加载、链接(验证、准备、解析)、初始化,在第一步加载的时候需要完成:

  • 通过一个类的全限定名来获取此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个类对象,代表加载的这个类,这个对象是java.lang.Class,它作为方法区这个类的各种数据访问的入口。

类对象和普通对象是不同的,类对象是在类加载的时候完成的,是jvm创建的并且是单例的,作为这个类和外界交互的入口, 而普通的对象一般是在调用new之后创建。

上面的第二条,将class字节流代表的静态存储结构转化为方法区的运行时数据结构,其中就包含了class文件常量池进入运行时常量池的过程,这里需要强调一下不同的类共用一个运行时常量池,同时在进入运行时常量池的过程中,多个class文件中常量池相同的字符串,多个class文件中常量池中相同的字符串只会存在一份在运行时常量池,这也是一种优化。

运行时常量池的作用是存储java class文件常量池中的符号信息,运行时常量池中保存着一些class文件中描述的符号引用,同时在类的解析阶段还会将这些符号引用翻译出直接引用(直接指向实例对象的指针,内存地址),翻译出来的直接引用也是存储在运行时常量池中。

运行时常量池相对于class常量池一大特征就是具有动态性,java规范并不要求常量只能在运行时才产生,也就是说运行时常量池的内容并不全部来自class常量池,在运行时可以通过代码生成常量并将其放入运行时常量池中,这种特性被用的最多的就是String.intern()。

2、全局字符串常量池

1)Java中创建字符串对象的两种方式

一般有如下两种:

String s0 = "hellow";
String s1 = new String("hellow");

第一种方式声明的字面量hellow是在编译期就已经确定的,它会直接进入class文件常量池中;当运行期间在全局字符串常量池中会保存它的一个引用.实际上最终还是要在堆上创建一个”hellow”对象,这个后面会讲。

第二种方式方式使用了new String(),也就是调用了String类的构造函数,我们知道new指令是创建一个类的实例对象并完成加载初始化的,因此这个字符串对象是在运行期才能确定的,创建的字符串对象是在堆内存上。

因此此时调用System.out.println(s0 == s1);返回的肯定是flase,因此==符号比较的是两边元素的地址,s1和s0都存在于堆上,但是地址肯定不相同。

// StringTable hashtable结构,不能扩容
public class Demo {
    // jvm在执行某个类的时候,class常量池中的信息,都会被加载到运行时常量池中,这时a b ab 都是常量池中的符号,还没有变成java对象
    public static void main(String[] args) {
        String s1="a";  // 从运行时常量池加载到字符串常量池的过程中是懒惰加载的,运行到这个代码才加载
        String s2="b";  // 当运行期间在全局字符串常量池中会保存它的一个引用.实际上最终还是要在堆上创建一个“b”对象
        String s3="ab";
        String s4=s1+s2;       // new StringBuffer().append("a").append("b").toString();

        String s5="a"+"b";      // javac 在编译期间的优化,结果在编译期间已经确定

        System.out.println(s3==s4);     // false
        System.out.println(s3==s5);     // true 都是串池中的字符串
    }
}

其中的Interned String就是全局共享的“字符串常量池(String Pool)”,和运行时常量池不是一个概念。但我们在代码中申明String s1 = "Hello";这句代码后,在类加载的过程中,类的class文件的信息会被解析到内存的方法区里。

class文件里常量池里大部分数据会被加载到“运行时常量池”,包括String的字面量;但同时“Hello”字符串的一个引用会被存到同样在“非堆”区域的“字符串常量池”中,而"Hello"本体还是和所有对象一样,创建在Java堆中。

当主线程开始创建s1时,虚拟机会先去字符串池中找是否有equals(“Hello”)的String,如果相等就把在字符串池中“Hello”的引用复制给s1;如果找不到相等的字符串,就会在堆中新建一个对象,同时把引用驻留在字符串池,再把引用赋给str。

当用字面量赋值的方法创建字符串时,无论创建多少次,只要字符串的值相同,它们所指向的都是堆中的同一个对象。

2)字符串常量池的本质

字符串常量池是JVM所维护的一个字符串实例的引用表,在HotSpot VM中,它是一个叫做StringTable的全局表。在字符串常量池中维护的是字符串实例的引用,底层C++实现就是一个Hashtable。这些被维护的引用所指的字符串实例,被称作”被驻留的字符串”或”interned string”或通常所说的”进入了字符串常量池的字符串”。

强调一下:运行时常量池在方法区(Non-heap),而JDK1.7后,字符串常量池被移到了heap区,因此两者根本就不是一个概念。

3) JAVA 基本类型的封装类及对应常量池

java中基本类型的包装类的大部分都实现了常量池技术,这些类是Byte,Short,Integer,Long,Character,Boolean,另外两种浮点数类型的包装类则没有实现。另外上面这5种整型的包装类也只是在对应值小于等于127时才可使用对象池,也即对象不负责创建和管理大于127的这些类的对象。

public class StringConstantPool{

    public static void main(String[] args){
        //5种整形的包装类Byte,Short,Integer,Long,Character的对象,
        //在值小于127时可以使用常量池
        Integer i1=127;
        Integer i2=127;
        System.out.println(i1==i2);//输出true

        //值大于127时,不会从常量池中取对象
        Integer i3=128;
        Integer i4=128;
        System.out.println(i3==i4);//输出false
        //Boolean类也实现了常量池技术

        Boolean bool1=true;
        Boolean bool2=true;
        System.out.println(bool1==bool2);//输出true

        //浮点类型的包装类没有实现常量池技术
        Double d1=1.0;
        Double d2=1.0;
        System.out.println(d1==d2); //输出false

    }
}

在JDK5.0之前是不允许直接将基本数据类型的数据直接赋值给其对应地包装类的,如:Integer i = 5; 但是在JDK5.0中支持这种写法,因为编译器会自动将上面的代码转换成如下代码:Integer i=Integer.valueOf(5);这就是Java的装箱.JDK5.0也提供了自动拆箱:Integer i =5; int j = i;

StringTable

特性

  • 常量池中的字符串仅是符号,第一次用到时才变为对象
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是 StringBuilder (1.8)
  • 字符串常量拼接的原理是编译期优化 可以使用 intern 方法,主动将串池中还没有的字符串对象放入串池
    • 1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回
    • 1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份, 放入串池, 会把串池中的对象返回

StringTable_intern_1.8

public static void main(String[] args) {

    /*
         *  将字符串a,b放入字符串池,并在堆中创建两个对象
         * 然后StringBuffer().append().append().toString;new String("ab")
         * 堆:new String("a"),new String("b"),new String("ab")
         * */
    String s = new String("a") + new String("b");   //此时字符串ab是不在字符串池里面的,在堆里面

    String s2 = s.intern(); //将这个字符串对象尝试放入串池中,如果有则不放入,如果没有则放入,并把串池中的对象放回    ab

    System.out.println(s2 == "ab");    // true
    System.out.println(s == "ab");     // true

}

public void test1() {
    String x = "ab";

    /*  将字符串a,b放入字符串池,并在堆中创建两个对象
         * 然后StringBuffer().append().append().toString;new String("ab")
         * 堆:new String("a"),new String("b"),new String("ab")
         */
    String s = new String("a") + new String("b");   //此时字符串ab是不在字符串池里面的,在堆里面

    String s2 = s.intern(); //将这个字符串对象尝试放入串池中,如果有则不放入,如果没有则放入,并把串池中的对象放回    ab

    System.out.println(s2 == x);    // true
    System.out.println(s == x);     // false
}

面试题

String s1 = "a";	//存在于字符串常量池
String s2 = "b";	//存在于字符串常量池
String s3 = "a" + "b";	//编译期间就优化了,“ab",存在于字符串常量池
String s4 = s1 + s2;	//两个变量,运行时才存在new String("ab"),存在于堆中
String s5 = "ab";	//存在于字符串常量池
String s6 = s4.intern();	
// 问
System.out.println(s3 == s4);	//false
System.out.println(s3 == s5);	//true
System.out.println(s3 == s6);	//true
String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
System.out.println(x1 == x2);	//false,jdk1.6的话则为false,调换的话为true

StringTable位置推导

/**
 * 演示 StringTable 位置
 * 在jdk8下设置 -Xmx10m -XX:-UseGCOverheadLimit
 * 在jdk6下设置 -XX:MaxPermSize=10m
 */
public class Test {

    public static void main(String[] args) throws InterruptedException {
        List<String> list = new ArrayList<String>();
        int i = 0;
        try {
            for (int j = 0; j < 260000; j++) {
                list.add(String.valueOf(j).intern());
                i++;
            }
        } catch (Throwable e) {
            e.printStackTrace();
        } finally {
            System.out.println(i);
        }
    }
}

当是在jdk6的环境下出现的错误为内存溢出,永久代空间不足

当是在jdk8的环境下出现的错误为内存溢出,堆空间不足

StringTable调优

  • 调整 -XX:StringTableSize=桶个数,增加哈希查找的次数
  • 考虑将字符串对象入池

直接内存

Direct Memory

  • 常见于 NIO 操作时,用于数据缓冲区

    /**
     * 演示 ByteBuffer 作用
     */
    public class Demo1_9 {
        static final String FROM = "E:\\编程资料\\第三方教学视频\\youtube\\Getting Started with Spring Boot-sbPSjI4tt10.mp4";
        static final String TO = "E:\\a.mp4";
        static final int _1Mb = 1024 * 1024;
    
        public static void main(String[] args) {
            io(); // io 用时:1535.586957 1766.963399 1359.240226
            directBuffer(); // directBuffer 用时:479.295165 702.291454 562.56592
        }
    
        private static void directBuffer() {
            long start = System.nanoTime();
            try (FileChannel from = new FileInputStream(FROM).getChannel();
                 FileChannel to = new FileOutputStream(TO).getChannel();
            ) {
                ByteBuffer bb = ByteBuffer.allocateDirect(_1Mb);
                while (true) {
                    int len = from.read(bb);
                    if (len == -1) {
                        break;
                    }
                    bb.flip();
                    to.write(bb);
                    bb.clear();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
            long end = System.nanoTime();
            System.out.println("directBuffer 用时:" + (end - start) / 1000_000.0);
        }
    
        private static void io() {
            long start = System.nanoTime();
            try (FileInputStream from = new FileInputStream(FROM);
                 FileOutputStream to = new FileOutputStream(TO);
            ) {
                byte[] buf = new byte[_1Mb];
                while (true) {
                    int len = from.read(buf);
                    if (len == -1) {
                        break;
                    }
                    to.write(buf, 0, len);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
            long end = System.nanoTime();
            System.out.println("io 用时:" + (end - start) / 1000_000.0);
        }
    }
    
  • 分配回收成本较高,但读写性能高

  • 不受 JVM 内存回收管理,必须调用Unsafe类的freeMemory()方法来释放

    public class DirectMemoryDemo {
    
        public static void main(String[] args) throws IOException {
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
            System.out.println("分配完毕");
            System.in.read();
            System.out.println("开始释放");
            byteBuffer=null;
            System.gc;
            System.in.read();
        }
    }
    
    
    • 查看allocateDirect()代码实现

      public static ByteBuffer allocateDirect(int capacity) {
          return new DirectByteBuffer(capacity);
      }
      
    • DirectByteBuffer

      image-20210204152056931

    • 真正的释放内存是在new Deallocator(base, size, cap)中

      image-20210204152146619

    • 当Cleaner.create监听到this对象被释放,也就是buffer被jvm回收时,会自动触发释放内存的运行方法

      image-20210204152327043

总结:分配和回收原理

  • 使用了 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法
  • ByteBuffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦 ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调 用 freeMemory 来释放直接内存

禁用显示垃圾回收对内存的影响

System.gc()	//显示的垃圾回收,Full GC

当设置参数:

-XX:+DisableExplicitGC	//禁用显示的垃圾回收

为什么要禁用,在调试优化的时候,代码中可能有很多地方进行了System.gc(),然后会出发Full GC极大的影响效率,所以禁用,但禁用了则不能释放直接内存了,因为这样就监听不到ByteBuffer 对象被回收了,这时可以手动调用Unsafe类的freMemory()方法来释放内存。

垃圾回收

如何判断对象可以回收

引用计数算法很简单,它实际上是通过在对象头中分配一个空间来保存该对象被引用的次数。如果该对象被其它对象引用,则它的引用计数加一,如果删除对该对象的引用,那么它的引用计数就减一,当该对象的引用计数为0时,那么该对象就会被回收。

比如说,当我们编写以下代码时,

String p = new String("abc")

abc这个字符串对象的引用计数值为1.

而当我们去除abc字符串对象的引用时,则abc字符串对象的引用计数减1

p = null

但此方式有个缺陷,但方法之间互相调用时,这两个对象就一直不会被回收!

image-20210204181233048

可达性分析算法

可达性分析(Reachability Analysis):通过一系列的称为 "GC Roots" (GC根)的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(就是从GC Roots到对象不可达)时,则证明此对象是不可用的,主流的开发语言都是使用的这种方式判断对象是否存活的。如下图所示,object5,object6,object7虽然相互关联,但是GC Roots是不可达的,所以这些对象是可回收的。

img

  • Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象

  • 扫描堆中的对象,看是否能够沿着 GC Root 对象 为起点的引用链找到该对象,找不到,表示可以回收

  • 哪些对象可以作为 GC Root ?

判断垃圾_可达分析_根对象

Java中可以作为GC Roots的对象主要有:

  • 虚拟机栈(栈帧中的本地变量表)中引用对象
  • 方法区中的类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈JNI的引用对象
  • 激活状态的线程
  • 正在被用于同步的各种锁对象
  • Class 由系统类加载器(system class loader)加载的对象,这些类不可以被回收,他们可以以静态字段的方式持有其它对象

如何查看GC Roots有哪些?

# 查看该程序的进程id
	jps
# 抓取程序运行到某一个时刻堆内存的情况
    jmap -dump:format=b,live,file=文件名.bin 进程id

四种引用

https://blog.csdn.net/u012988901/article/details/99317272

  1. 强引用
    • 只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收
  2. 软引用(SoftReference)
    • 仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收,回收软引用 对象
    • 可以配合引用队列来释放软引用自身
  3. 弱引用(WeakReference)
    • 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象 可以配合引用队列来释放弱引用自身
  4. 虚引用(PhantomReference)
    • 必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队, 由 Reference Handler 线程调用虚引用相关方法释放直接内存
  5. 终结器引用(FinalReference)
    • 无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象 暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC 时才能回收被引用对象

强引用 (StrongReference)

强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。如下:

Object o=new Object();   //  强引用

当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。如果不使用时,要通过如下方式来弱化引用,如下:

o=null;     // 帮助垃圾收集器回收此对象

​ 显式地设置o为null,或超出对象的生命周期范围,则gc认为该对象不存在引用,这时就可以回收这个对象。具体什么时候收集这要取决于gc的算法。
举例:

public void test(){
    Object o=new Object();
    // 省略其他操作
}

在一个方法的内部有一个强引用,这个引用保存在栈中,而真正的引用内容(Object)保存在堆中。当这个方法运行完成后就会退出方法栈,则引用内容的引用不存在,这个Object会被回收。
但是如果这个o是全局的变量时,就需要在不用这个对象时赋值为null,因为强引用不会被垃圾回收。

强引用在实际中有非常重要的用处,举个ArrayList的实现源代码:

private transient Object[] elementData;
public void clear() {
        modCount++;
        // Let gc do its work
        for (int i = 0; i < size; i++)
            elementData[i] = null;
        size = 0;
}

​ 在ArrayList类中定义了一个私有的变量elementData数组,在调用方法清空数组时可以看到为每个数组内容赋值为null。不同于elementData=null,强引用仍然存在,避免在后续调用 add()等方法添加元素时进行重新的内存分配。使用如clear()方法中释放内存的方法对数组中存放的引用类型特别适用,这样就可以及时释放内存。

软引用(SoftReference)

​ 如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

String str=new String("abc");                                     // 强引用
SoftReference<String> softRef=new SoftReference<String>(str);     // 软引用  

当内存不足时,等价于:

If(JVM.内存不足()) {
   str = null;  // 转换为软引用
   System.gc(); // 垃圾回收器进行回收
}

​ 虚引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。

(1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建

(2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出

这时候就可以使用软引用

Browser prev = new Browser();               // 获取页面进行浏览
SoftReference sr = new SoftReference(prev); // 浏览完毕后置为软引用        
if(sr.get()!=null){ 
    rev = (Browser) sr.get();           // 还没有被回收器回收,直接获取
}else{
    prev = new Browser();               // 由于内存吃紧,所以对软引用的对象回收了
    sr = new SoftReference(prev);       // 重新构建
}

这样就很好的解决了实际的问题。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。

移除软引用:

/**
 * 演示软引用, 配合引用队列
 * -Xmx20m -XX:+PrintGCDetails -verbose:gc
 */
public class Demo2_4 {
    private static final int _4MB = 4 * 1024 * 1024;

    public static void main(String[] args) {
        List<SoftReference<byte[]>> list = new ArrayList<>();

        // 引用队列
        ReferenceQueue<byte[]> queue = new ReferenceQueue<>();

        for (int i = 0; i < 5; i++) {
            // 关联了引用队列, 当软引用所关联的 byte[]被回收时,软引用自己会加入到 queue 中去
            SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
            System.out.println(ref.get());
            list.add(ref);
            System.out.println(list.size());
        }

        // 从队列中获取无用的 软引用对象,并移除
        Reference<? extends byte[]> poll = queue.poll();
        while( poll != null) {
            list.remove(poll);
            poll = queue.poll();
        }

        System.out.println("===========================");
        for (SoftReference<byte[]> reference : list) {
            System.out.println(reference.get());
        }

    }
}

image-20210204195756690

弱引用(WeakReference)

​ 弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。

String str=new String("abc");    
WeakReference<String> abcWeakRef = new WeakReference<String>(str);
str=null;  

当垃圾回收器进行扫描回收时等价于:

str = null;
System.gc();

如果这个对象是偶尔的使用,并且希望在使用时随时就能获取到,但又不想影响此对象的垃圾收集,那么你应该用 Weak Reference 来记住此对象。

虚引用(PhantomReference)

“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。

虚引用主要用来跟踪对象被垃圾回收器回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。


垃圾回收算法

标记清除算法(Mark-Sweep)

标记—清除算法是最基础的收集算法,它分为“标记”和“清除”两个阶段:首先标记出所需回收的对象,在标记完成后统一回收掉所有被标记的对象,它的标记过程其实就是前面的可达性分析算法中判定垃圾对象的标记过程。标记—清除算法的执行情况如下图所示:

  • 回收前状态

    img

  • 回收后状态

    img

  • 注意:该算法是沿着GC Root找未在根节点上的内存空间,如果不在根上则将该垃圾对象释放,所谓的释放不是将该内存进行清零操作,而是记录此处内存的起始和结束的地址,放到一个空闲的列表中,当需要使用该内存则去该列表中找是否可以容纳,如果可以则覆盖原先的内容。

缺点:

  • 标记和清除过程的效率都不高
  • 标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不触发另一次垃圾收集动作

复制

复制算法是针对标记—清除算法的缺点,在其基础上进行改进而得到的,它将可用内存按容量分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另外一块内存上面,然后再把已使用过的内存空间一次清理掉。再交换两块内存的位置。

复制算法有如下优点:

  • 每次只对一块内存进行回收,运行高效
  • 只需移动栈顶指针,按顺序分配内存即可,实现简单
  • 内存回收时不用考虑内存碎片的出现

缺点:

​ 可一次性分配的最大内存缩小了一半

复制算法的执行情况如下图所示:

image-20210205151035421


标记整理

复制算法比较适合于新生代,在老年代中,对象存活率比较高,如果执行较多的复制操作,效率将会变低,所以老年代一般会选用其他算法,如标记—整理算法。该算法标记的过程与标记—清除算法中的标记过程一样,但对标记后出的垃圾对象的处理情况有所不同,它不是直接对可回收对象进行清理,而是让所有的对象都向一端移动,然后直接清理掉端边界以外的内存。标记—整理算法的回收情况如下所示:

  • 回收前状态:

    img

  • 回收后状态:

    img

缺点:

​ 将垃圾对象清楚之后,要让内存紧凑的同时需要移动原有的内存,导致内存的地址发生变化,当其他程序使用时发重新寻址,导致消耗更多的时间。


增量算法(Incremental Collecting)

在垃圾回收过程中,应用软件将处于一种 CPU 消耗很高的状态。在这种 CPU 消耗很高的状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。

增量算法的基本思想是,如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成

使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。


分代垃圾回收

为什么需要分代回收

​ 在不进行对象存活时间区分的情况下,每次垃圾回收都是对整个堆空间进行回收,花费时间相对会长,同时,因为每次回收都需要遍历所有存活对象,但实际上,对于生命周期长的对象而言,这种遍历是没有效果的,因为可能进行了很多次遍历,但是他们依旧存在。因此,分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收

如何进行分代

当前商业虚拟机的垃圾收集都采用分代收集,它根据对象的存活周期的不同将内存划分为几块,一般是把Java堆分为新生代老年代

  • 在新生代中,每次垃圾收集时都会发现有大量对象死去,只有少量存活,因此可选用复制算法来完成收集
  • 老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用标记—清除算法标记—整理算法来进行回收
GC算法 优点 缺点 存活对象移动 内存碎片 适用场景
标记清除 不需要额外空间 两次扫描,耗时严重 N Y 老年代
复制 没有标记和清除 需要额外空间 Y N 新生代
标记整理 没有内存碎片 需要移动对象的成本 Y N 老年代

分代回收特点:

  • 对象首先分配在伊甸园区域 ,大对象可以直接进入老年代
  • 新生代空间不足时,触发 minor gc,伊甸园和 from 存活的对象使用 copy 复制到 to 中,存活的对象年龄加 1并且交换 from to
  • 虚拟机提供了-XX:PretenureSizeThreshold参数,大于这个参数值的对象将直接分配到老年代中。因为新生代采用的是复制算法,在Eden中分配大对象将会导致Eden区和两个Survivor区之间大量的内存拷贝。
  • minor gc 会引发 stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
  • 当对象寿命超过阈值时,会晋升至老年代,最大寿命是15(4bit)
  • 当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍不足,那么触发 full gc,STW的时间更长
  • 在多线程环境下,一个线程发生OOM不会导致其他线程结束

为什么需要2块幸存区空间?

image-20210205161541139

​ 这里涉及到一个新生代和老年代的存活周期的问题,比如一个对象在新生代经历15次(仅供参考)GC,就可以移到老年代了。问题来了,当我们第一次GC的时候,我们可以把Eden区的存活对象放到Survivor A空间,但是第二次GC的时候,Survivor A空间的存活对象也需要再次用Copying算法,放到Survivor B空间上,而把刚刚的Survivor A空间和Eden空间清除。第三次GC时,又把Survivor B空间的存活对象复制到Survivor A空间,如此反复。


相关VM参数

含义 参数
堆初始大小 -Xms
堆最大大小 -Xmx 或 -XX:MaxHeapSize=size
新生代大小 -Xmn 或 (-XX:NewSize=size + -XX:MaxNewSize=size )
幸存区比例(动态) -XX:InitialSurvivorRatio=ratio 和 -XX:+UseAdaptiveSizePolicy
幸存区比例 -XX:SurvivorRatio=ratio
晋升阈值 XX:MaxTenuringThreshold=threshold
晋升详情 -XX:+PrintTenuringDistribution
GC详情 -XX:+PrintGCDetails -verbose:gc
FullGC 前 MinorGC -XX:+ScavengeBeforeFullGC

垃圾回收器

  1. 串行
    • 单线程
    • 堆内存较小,适合个人电脑
  2. 吞吐量优先
    • 多线程
    • 堆内存较大,多核 cpu 让单位时间内,STW 的时间最短 (0.2 0.2 = 0.4),垃圾回收时间占比最低,这样就称吞吐量高
  3. 响应时间优先
    • 多线程
    • 堆内存较大,多核 cpu 尽可能让单次 STW 的时间最短 (0.1 0.1 0.1 0.1 0.1 = 0.5)

img

串行

-XX:+UserSerialGC=Serial + SerialOld

image-20210205202257057

Serial 垃圾回收器

# 开启Serial
	-XX:+UseSerialGC

Serial收集器是最基本的、发展历史最悠久的收集器。俗称为:串行回收器,采用复制算法进行垃圾回收。

特点:

串行回收器是指使用单线程进行垃圾回收的回收器。每次回收时,串行回收器只有一个工作线程。

对于并行能力较弱的单CPU计算机来说,串行回收器的专注性和独占性往往有更好的性能表现。

它存在Stop The World问题,及垃圾回收时,要停止程序的运行。

使用-XX:+UseSerialGC参数可以设置新生代使用这个串行回收器

SerialOld 垃圾回收器

Serial Old收集器也是个单线程收集器,适用于老年代,使用的是标记-整理算法,可以配合Serial收集器在Client模式下使用。

它可以作为CMS收集器的后备预案,如果CMS出现Concurrent Mode Failure,则SerialOld将作为后备收集器。

用途:

  • 一个是在JDK1.5及之前的版本中与Parallel Scavenge(并行清除)收集器搭配使用,
  • 另一个就是作为CMS(并发标记清除)收集器的后备预案,如果CMS出现Concurrent Mode Failure,则SerialOld将作为后备收集器。

使用算法:标记 - 整理算法


吞吐量优先

吞吐量=用户线程执行时间/(用户线程执行时间+垃圾收集时间)

# 开启该回收算法,开启了其中一个后另外一个会默认开启
	-XX:+UseParallelGC ~ -XX:+UseParallelOldGC
# 开启新生区内存比例,晋升阈值自适应变化
	-XX:+UseAdaptiveSizePolicy
# 调整吞吐量目标 1/(1+ratio) 将堆调大,提升吞吐量
	-XX:GCTimeRatio=ratio
# 默认200ms 默认
	-XX:MaxGCPauseMillis=ms
# 通过n设置并发清理线程的数量
	-XX:ParallelGCThreads=n

image-20210205204253308

ParNew收集器----并行,垃圾回收器并行

ParNew收集器是Serial收集器的多线程版本;除了使用了多线程进行垃圾收集以外,其他的都和Serial一致。它默认开始的线程数与CPU的核数相同,可以通过参数-XX:ParallelGCThreads来设置线程数。

Parallel Old收集器----并行,垃圾回收器并行

Parallel Old收集器可以配合Parallel Scavenge收集器一起使用达到“吞吐量优先”,它主要是针对老年代的收集器,使用的是标记-整理算法。在注重吞吐量的任务中可以优先考虑使用这个组合


响应时间优先(cms)

CMS收集器是一种以获取最短回收停顿时间为目标的收集器,在互联网网站、B/S架构的中常用的收集器就是CMS,因为系统停顿的时间最短,给用户带来较好的体验。

# 设置老年代使用该回收器
	XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
# 前者为并行线程数,一般为cpu核心个数,后者为并发垃圾清理线程的个数,一般为前者的1/4,如4个核心数目中有1个做垃圾回收
	-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads
# 控制何时进行CMS垃圾回收,设置内存的内存占比触发CMS
	-XX:CMSInitiatingOccupancyFraction=percent
# 
	-XX:+CMSScavengeBeforeRemark

CMS采用的是标记-清除算法,主要分为了4个步骤:

  • 初始化标记
  • 并发标记
  • 重新标记
  • 并发清除

初始化标记和重新标记这两个步骤依然会发生Stop The World,初始化标记只是标记GC Root能够直接关联到的对象,速度较快,并发标记能够和用户线程并发执行;重新标记是为了修正在并发标记的过程中用户线程产生的垃圾,这个时间比初始化标记稍长,比并发标记短很多。整个过程请看下图

image-20210205212927775

优点

  • CMS是一款优秀的收集器,它的主要优点:并发收集、低停顿,因此CMS收集器也被称为并发低停顿收集器(Concurrent Lsow Pause Collector)。

缺点

  • CMS收集器对CPU资源非常敏感。 在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个时(比如2个),CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%,其实也让人无法接受。
  • 无法处理浮动垃圾。 由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS无法再当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就被称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,回收阀值可以通过参数-XX:CMSInitiatingoccupancyFraction来设置;如果回收阀值设置的太大,在CMS运行期间如果分配大的对象找不到足够的空间就会出现“Concurrent Mode Failure”失败,这时候会临时启动SerialOld GC来重新进行老年代的收集,这样的话停顿的时间就会加长。
  • 标记-清除算法导致的空间碎片 CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象。为了解决这个问题CMS提供了一个参数-XX:+UseCMSCompactAtFullCollecion,如果启用,在Full GC的时候开启内存碎片整理合并过程,由于内存碎片整理的过程无法并行执行,所以停顿的时间会加长。考虑到每次FullGC都要进行内存碎片合并不是很合适,所以CMS又提供了另一个参数-XX:CMSFullGCsBeforeCompaction来控制执行多少次不带碎片整理的FullGC之后,来一次带碎片整理GC

G1

参考博客:https://blog.csdn.net/coderlius/article/details/79272773

定义:Garbage First

  • 2004 论文发布
  • 2009 JDK 6u14 体验
  • 2012 JDK 7u4 官方支持
  • 2017 JDK 9 默认

适用场景

  • 同时注重吞吐量(Throughput)和低延迟(Low latency),默认的暂停目标是 200 ms
  • 超大堆内存,会将堆划分为多个大小相等的 Region
  • 整体上是 标记+整理 算法,两个区域之间是 复制 算法

相关 JVM 参数

-XX:+UseG1GC 
-XX:G1HeapRegionSize=size 
-XX:MaxGCPauseMillis=time

G1垃圾回收阶段

阶段模型:

image-20210206165727764

Young Collection

  • 会发生STW

  • 将堆内存划分为多个区域,每一个区域都可以表示为一个伊甸园区域

    image-20210206165807057

  • 当伊甸区内存紧张的时候,会放入幸存区

    image-20210206165958328

  • 幸存区内对象超过一定的年龄后会进入老年区,并触发一次垃圾回收

    image-20210206170049405

Young Collection + CM

CM:current mark 并发标记

  • 在 Young GC 时会进行 GC Root 的初始标记

  • 老年代占用堆空间比例达到阈值时,进行并发标记(不会 STW),由下面的 JVM 参数决定

    # 设置老年代占用堆空间的阈值,默认为45%
    	-XX:InitiatingHeapOccupancyPercent=percent 
    

image-20210206170903125

Mixed Collention

会对 E、S、O 进行全面垃圾回收

  • 最终标记(Remark)会 STW
  • 拷贝存活(Evacuation)会 STW
  • -XX:MaxGCPauseMillis=ms

在最大暂停时间内,回收老年代中最具有价值的对象

image-20210206170958374

Full GC

  • SerialGC
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足发生的垃圾收集 - full gc
  • ParallelGC
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足发生的垃圾收集 - full gc
  • CMS
    • 新生代内存不足发生的垃圾收集 - minor gc
    • 老年代内存不足
  • G1
    • 新生代内存不足发生的垃圾收集
    • minor gc 老年代内存不足

Young Collection 跨代引用

image-20210207142613934

  • 卡表与Remembered Set
  • 在引用变更时通过 post-write barrier + dirty card queue
  • concurrent refinement threads 更新 Remembered Set

image-20210207142658485

Remark

  • pre-write barrier + satb_mark_queue

image-20210207142721738Jdk 8u20字符串去重

  • 优点:节省大量内存
  • 缺点:略微多占用了 cpu 时间,新生代回收时间略微增加
  • -XX:+UseStringDeduplication
String s1 = new String("hello"); // char[]{'h','e','l','l','o'}
String s2 = new String("hello"); // char[]{'h','e','l','l','o'}
  • 将所有新分配的字符串放入一个队列
  • 当新生代回收时,G1并发检查是否有字符串重复
  • 如果它们值一样,让它们引用同一个 char[]
  • 注意,与 String.intern() 不一样
    • String.intern() 关注的是字符串对象
    • 而字符串去重关注的是 char[]
    • 在 JVM 内部,使用了不同的字符串表

JDK 8u40 并发标记类卸载

所有对象都经过并发标记后,就能知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它所加载的所有类-XX:+ClassUnloadingWithConcurrentMark默认启用

JDK 8u60 回收巨型对象

  • 一个对象大于 region 的一半时,称之为巨型对象
  • G1 不会对巨型对象进行拷贝
  • 回收时被优先考虑
  • G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为0 的巨型对象就可以在新生 代垃圾回收时处理掉

JDK 9 并发标记起始时间的调整

  • 并发标记必须在堆空间占满前完成,否则退化为 FullGC
  • JDK 9 之前需要使用 -XX:InitiatingHeapOccupancyPercent
  • JDK 9 可以动态调整
    • -XX:InitiatingHeapOccupancyPercent 用来设置初始值
    • 进行数据采样并动态调整
    • 总会添加一个安全的空档空间

GC调优

查看虚拟机运行参数:

“c:xxxx(jdk安装的目录)\bin\java" -XX:+PrintFlagsFinal -version / findstr "GC"
  • 掌握 GC 相关的 VM 参数,会基本的空间调整
  • 掌握相关工具
  • 明白一点:调优跟应用、环境有关,没有放之四海而皆准的法则

调优领域

  • 内存
  • 锁竞争
  • cpu
  • 占用 io

最快的GC是不发生GC

  • GC 查看 FullGC 前后的内存占用,考虑下面几个问题
  • 数据是不是太多?
    • resultSet = statement.executeQuery("select * from 大表")
  • 据表示是否太臃肿?
    • 对象图 ,多表关联查询,查询出了所有的字段信息,但实际上只用到了其中一部分
    • 对象大小,例如一个包装类型24个字节,但一个int类型只需4个字节
  • 是否存在内存泄漏?
    • static Map map ,定义了一个静态的map,一直往其中放对象,但不进行移除操作
    • 第三方缓存实现

新生代调优

  • 新生代的特点

    • 所有的 new 操作的内存分配非常廉价
      • TLAB thread-local allocation buffer ,在伊甸区中每一个线程都有一个本地的线程缓冲区来创建对象
      • 应用线程可以独占一个本地缓冲区(TLAB)来创建的对象,而大部分都会落入Eden区域(巨型对象或分配失败除外),因此TLAB的分区属于Eden空间;而每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,每次回收会将对象复制到Suvivor空间或老年代空间;对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。
    • 死亡对象的回收代价是零
    • 大部分对象用过即死
    • Minor GC 的时间远远低于 Full GC
  • 新生代越大越好吗?

    -Xmn Sets the initial and maximum size (in bytes) of the heap for the young generation (nursery).
    GC is performed in this region more often than in other regions. If the size for the young
    generation is too small, then a lot of minor garbage collections are performed. If the size is too
    large, then only full garbage collections are performed, which can take a long time to complete.
    Oracle recommends that you keep the size for the young generation greater than 25% and less
    than 50% of the overall heap size.
    
    通过—Xmn来设置新生代的大小,新生代的大小不是越大越好,当太小时,新生代会频繁的触发minor gc,造成stop the world,当太大时,由于堆空间的总内存是有限的,新生代内存大,老年代就会小,老年代内存过小可能会频繁的造成full gc。
    Oracle推荐新生代的内存大于堆的1/4,小于堆的1/2。
    
  • 新生代能容纳所有【并发量 * (请求-响应)】的数据

  • 幸存区大到能保留【当前活跃对象+需要晋升对象】

  • 当幸存区过小时,存在于幸存区的对象就很容易晋升到老年区,直到Full GC的时候才被清除

  • 晋升阈值配置得当,让长时间存活对象尽快晋升

    • -XX:MaxTenuringThreshold=threshold
    • -XX:+PrintTenuringDistribution
    Desired survivor size 48286924 bytes, new threshold 10 (max 10)
    - age 1: 28992024 bytes, 28992024 total
    - age 2: 1366864 bytes, 30358888 total
    - age 3: 1425912 bytes, 31784800 total
    ...
    

老年代调优

以 CMS 为例

  • CMS 的老年代内存越大越好

  • 先尝试不做调优,如果没有 Full GC 那么老年代则不需要调优,否则先尝试调优新生代

  • 观察发生 Full GC 时老年代内存占用,将老年代内存预设调大 1/4 ~ 1/3

  • # 当老年代的内存空间使用了多少百分比时触发CMS
    	-XX:CMSInitiatingOccupancyFraction=percent
    

案例

  • 案例1 Full GC 和 Minor GC频繁 ,新生代内存过小,导致GC频繁
  • 案例2 请求高峰期发生 Full GC,单次暂停时间特别长 (CMS),业务高峰时当内存紧张时,CMS重新标记阶段时间很长,可以设置参数-XX:+CMSScavengeBeforeReamrk在进行重新标记之前进行一次minor GC清楚掉一些垃圾使重新标记时间减少
  • 案例3 老年代充裕情况下,发生 Full GC (CMS jdk1.7),再jdk1.8以前的版本中,永久代的内存不足时也会触发Full GC,1.8以后移除了永久代变为了元空间,垃圾回收由操作系统控制

字节码技术

image-20210208101308377

javap 工具

自己分析类文件结构太麻烦了,Oracle 提供了 javap 工具来反编译 class 文件

[root@localhost ~]# javap -v HelloWorld.class
Classfile /root/HelloWorld.class
Last modified Jul 7, 2019; size 597 bytes
MD5 checksum 361dca1c3f4ae38644a9cd5060ac6dbc
Compiled from "HelloWorld.java"
public class cn.itcast.jvm.t5.HelloWorld
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#21 // java/lang/Object."<init>":()V
#2 = Fieldref #22.#23 //
java/lang/System.out:Ljava/io/PrintStream;
#3 = String #24 // hello world
#4 = Methodref #25.#26 // java/io/PrintStream.println:
(Ljava/lang/String;)V
#5 = Class #27 // cn/itcast/jvm/t5/HelloWorld
#6 = Class #28 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcn/itcast/jvm/t5/HelloWorld;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 MethodParameters
#19 = Utf8 SourceFile
#20 = Utf8 HelloWorld.java
#21 = NameAndType #7:#8 // "<init>":()V
#22 = Class #29 // java/lang/System
#23 = NameAndType #30:#31 // out:Ljava/io/PrintStream;
#24 = Utf8 hello world
#25 = Class #32 // java/io/PrintStream
#26 = NameAndType #33:#34 // println:(Ljava/lang/String;)V
#27 = Utf8 cn/itcast/jvm/t5/HelloWorld
#28 = Utf8 java/lang/Object
#29 = Utf8 java/lang/System
#30 = Utf8 out
#31 = Utf8 Ljava/io/PrintStream;
#32 = Utf8 java/io/PrintStream
#33 = Utf8 println
#34 = Utf8 (Ljava/lang/String;)V
{
public cn.itcast.jvm.t5.HelloWorld();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."
<init>":()V
4: return
LineNumberTable:
line 4: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcn/itcast/jvm/t5/HelloWorld;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field
java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello world
5: invokevirtual #4 // Method
java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 6: 0
line 7: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
MethodParameters:
Name Flags
args
}


图解方法执行流程

原始 java 代码

public class JavapTest {
    public static void main(String[] args) {
        int a = 10;
        int b = Short.MAX_VALUE + 1;
        int c = a + b;
        System.out.println(c);
    }
}

编译后的字节码文件

D:\IdeaProjects\utils\jsoup\src\test\java>javap -v JavapTest.class
Classfile /D:/IdeaProjects/utils/jsoup/src/test/java/JavapTest.class
  Last modified 2021-2-8; size 436 bytes
  MD5 checksum 55d016f91f579d52f0e3f4fb4e24b71f
  Compiled from "JavapTest.java"
public class JavapTest
  minor version: 0
  major version: 52		//jdk8
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #7.#16         // java/lang/Object."<init>":()V
   #2 = Class              #17            // java/lang/Short
   #3 = Integer            32768
   #4 = Fieldref           #18.#19        // java/lang/System.out:Ljava/io/PrintStream;
   #5 = Methodref          #20.#21        // java/io/PrintStream.println:(I)V
   #6 = Class              #22            // JavapTest
   #7 = Class              #23            // java/lang/Object
   #8 = Utf8               <init>
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               main
  #13 = Utf8               ([Ljava/lang/String;)V
  #14 = Utf8               SourceFile
  #15 = Utf8               JavapTest.java
  #16 = NameAndType        #8:#9          // "<init>":()V
  #17 = Utf8               java/lang/Short
  #18 = Class              #24            // java/lang/System
  #19 = NameAndType        #25:#26        // out:Ljava/io/PrintStream;
  #20 = Class              #27            // java/io/PrintStream
  #21 = NameAndType        #28:#29        // println:(I)V
  #22 = Utf8               JavapTest
  #23 = Utf8               java/lang/Object
  #24 = Utf8               java/lang/System
  #25 = Utf8               out
  #26 = Utf8               Ljava/io/PrintStream;
  #27 = Utf8               java/io/PrintStream
  #28 = Utf8               println
  #29 = Utf8               (I)V
{
  public JavapTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 5: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1
         0: bipush        10
         2: istore_1
         3: ldc           #3                  // int 32768
         5: istore_2
         6: iload_1
         7: iload_2
         8: iadd
         9: istore_3
        10: getstatic     #4                  // Field java/lang/System.out:Ljava/io/PrintStream;
        13: iload_3
        14: invokevirtual #5                  // Method java/io/PrintStream.println:(I)V
        17: return
      LineNumberTable:
        line 7: 0
        line 8: 3
        line 9: 6
        line 10: 10
        line 11: 17
}
SourceFile: "JavapTest.java"

class常量池载入运行时常量池

比较小的数字不会存入运行时常量池,会和字节码指令存放在一起

image-20210208102240112

方法字节码载入方法区

image-20210208103345488

main 线程开始运行,分配栈帧内存

(stack=2,locals=4)

image-20210208103428178

执行引擎开始执行字节码

bipush 10 :

  • 将一个 byte 压入操作数栈(其长度会补齐 4 个字节),类似的指令还有
  • sipush 将一个 short 压入操作数栈(其长度会补齐 4 个字节)
  • ldc 将一个 int 压入操作数栈 ldc2_w 将一个
  • long 压入操作数栈(分两次压入,因为 long 是 8 个字节)
  • 这里小的数字都是和字节码指令存在一起,超过 short 范围的数字存入了常量池

image-20210208103941270

istore_1:

  • 将操作数栈顶数据弹出,存入局部变量表的 slot 1

image-20210208104124392

image-20210208104210696

ldc #3:

  • 从常量池加载 #3 数据到操作数栈
  • 注意: Short.MAX_VALUE 是 32767,所以 32768 = Short.MAX_VALUE + 1 实际是在编译期间计算 好的

image-20210208104708744

istore_2:

image-20210208104745531

image-20210208105045578

iload_1:

image-20210208105350063

iload_2:

image-20210208105406762

iadd:

image-20210208105443645

image-20210208105532256

istore_3:

image-20210208105621117

image-20210208105648612

getstatic #4:只是获取引用

image-20210208105752506

image-20210208105902081

iload_3:

image-20210208110042125

image-20210208110102617

invokevirtual #5:

  • 找到常量池 #5 项
  • 定位到方法区 java/io/PrintStream.println:(I)V 方法
  • 生成新的栈帧(分配 locals、stack等)
  • 传递参数,执行新栈帧中的字节码

image-20210208110133897

  • 执行完毕,弹出栈帧
  • 清除 main 操作数栈内容

return:

  • 完成 main 方法调用,弹出 main 栈帧
  • 程序结束

条件判断指令

字节码指令手册:https://www.cnblogs.com/xpwi/p/11360692.html

几点说明:

  • byte,short,char 都会按 int 比较,因为操作数栈都是 4 字节
  • goto 用来进行跳转到指定行号的字节码

源码:

public class Demo3_3 {
public static void main(String[] args) {
	int a = 0;
	if(a == 0) {
		a = 10;
	} else {
		a = 20;
		}
	}
}

字节码:

0: iconst_0		//定义常量a
1: istore_1		//给a赋值
2: iload_1
3: ifne 12
6: bipush 10
8: istore_1
9: goto 15
12: bipush 20
14: istore_1
15: return

循环控制指令

while循环

public class Demo3_4 {
public static void main(String[] args) {
	int a = 0;
	while (a < 10) {
		a++;
		}
	}
}
0: iconst_0
1: istore_1
2: iload_1
3: bipush 10
5: if_icmpge 14
8: iinc 1, 1
11: goto 2
14: return

do whie循环

public class Demo3_5 {
public static void main(String[] args) {
	int a = 0;
	do {
		a++;
	} while (a < 10);
  }
}
0: iconst_0
1: istore_1
2: iload_1
3: bipush 10
5: if_icmpge 14
8: iinc 1, 1
11: goto 2
14: return

for循环

public class Demo3_6 {
public static void main(String[] args) {
	for (int i = 0; i < 10; i++) {
		}
	}	
}
0: iconst_0
1: istore_1
2: iload_1
3: bipush 10
5: if_icmpge 14
8: iinc 1, 1
11: goto 2
14: return

比较 while 和 for 的字节码,你发现它们是一模一样的,殊途也能同归

练习

public class Demo3_6_1 {
public static void main(String[] args) {
	int i = 0;
	int x = 0;
	while (i < 10) {
		x = x++;
		i++;
	}
		System.out.println(x); // 结果是 0
  }
}
  1. bipush,将x的值放入操作数栈中
  2. 将操作数栈中的x,istore_1,放入局部变量表的slot_1中
  3. 局部变量表中执行iinc x 1,x的值变为1
  4. 将操作数栈中的x,istore_1,覆盖给局部变量表中的x,此时局部变量表中的x为0

构造方法

类的构造方法---<cinit>()v

public class Demo3_8_1 {
	static int i = 10;
	static {
		i = 20;
	}
	static {
		i = 30;
	}
}

编译器会按从上至下的顺序,收集所有 static 静态代码块和静态成员赋值的代码,合并为一个特殊的方法<cinit>()v:

0: bipush 10
2: putstatic #2 // Field i:I
5: bipush 20
7: putstatic #2 // Field i:I
10: bipush 30
12: putstatic #2 // Field i:I
15: return

<cinit>()v方法会在类加载的初始化阶段被调用

有成员变量的构造方法---<init>()V

public class Demo3_8_2 {
	private String a = "s1";
	{
		b = 20;
	}
	private int b = 10;
	{
		a = "s2";
	}
    public Demo3_8_2(String a, int b) {
        this.a = a;
        this.b = b;
    }

    public static void main(String[] args){
        Demo3_8_2 d = new Demo3_8_2("s3",c);
        System.out.println(d.a);
		System.out.println(d.b);
    }

}

编译器会按从上至下的顺序,收集所有 {} 代码块和成员变量赋值的代码,形成新的构造方法,但原始构造方法内的代码总是在最后

public cn.itcast.jvm.t3.bytecode.Demo3_8_2(java.lang.String, int);
    descriptor: (Ljava/lang/String;I)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=3
        0: aload_0				//加载this到操作数栈
        1: invokespecial #1 // super.<init>()V
        4: aload_0
        5: ldc #2 // <- "s1"		//s1到操作数栈
        7: putfield #3 // -> this.a		//从运行时常量池中获取成员变量a
        10: aload_0
        11: bipush 20 // <- 20
        13: putfield #4 // -> this.b
        16: aload_0
        17: bipush 10 // <- 10
        19: putfield #4 // -> this.b
        22: aload_0
        23: ldc #5 // <- "s2"
        25: putfield #3 // -> this.a
        28: aload_0 // ------------------------------
        29: aload_1 // <- slot 1(a) "s3" |
        30: putfield #3 // -> this.a |
        33: aload_0 |
        34: iload_2 // <- slot 2(b) 30 |
        35: putfield #4 // -> this.b --------------------
        38: return
	LineNumberTable: ...
	LocalVariableTable:
	  Start Length Slot Name Signature
		0 39 0 this Lcn/itcast/jvm/t3/bytecode/Demo3_8_2;
        0 39 1 a Ljava/lang/String;
        0 39 2 b I
   MethodParameters: ...

方法调用

看一下几种不同的方法调用对应的字节码指令

public class Demo3_9 {
    public Demo3_9() { }
    
    private void test1() { }
    
	private final void test2(){ }
    
    public void test3(){ }
    
    public static void test4() { }
    
    public static void main(String[] args) {
        Demo3_9 d = new Demo3_9();
        d.test1();
        d.test2();
        d.test3();
        d.test4();
        Demo3_9.test4();
	}
}

字节码:

0: new #2 // class cn/itcast/jvm/t3/bytecode/Demo3_9
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1		
8: aload_1
9: invokespecial #4 // Method test1:()V
12: aload_1
13: invokespecial #5 // Method test2:()V
16: aload_1
17: invokevirtual #6 // Method test3:()V
20: aload_1
21: pop
22: invokestatic #7 // Method test4:()V
25: invokestatic #7 // Method test4:()V
28: return
  • new 是创建对象,给对象分配堆内存,执行成功会将对象引用压入操作数栈
  • dup 是复制操作数栈栈顶的内容,本例即为对象引用,为什么需要两份引用呢,一个是要配合 invokespecial 调用该对象的构造方法 "":()V (会消耗掉栈顶一个引用),另一个要 配合 astore_1 赋值给局部变量
  • 最终方法(final),私有方法(private),构造方法都是由 invokespecial 指令来调用,属于静态绑定
  • 普通成员方法是由 invokevirtual 调用,属于动态绑定,即支持多态 成员方法与静态方法调用的另一个区别是,执行方法前是否需要对象引用
  • 比较有意思的是 d.test4(); 是通过对象引用调用一个静态方法,可以看到在调用 invokestatic 之前执行了 pop 指令,把对象引用从操作数栈弹掉了
  • 还有一个执行 invokespecial 的情况是通过 super 调用父类方法

多态原理

当执行 invokevirtual 指令时,

  1. 先通过栈帧中的对象引用找到对象
  2. 分析对象头,找到对象的实际 Class
  3. Class 结构中有 vtable,它在类加载的链接阶段就已经根据方法的重写规则生成好了
  4. 查表得到方法的具体地址
  5. 执行方法的字节码

异常处理

try-catch

public class Demo3_11_1 {
	public static void main(String[] args) {
		int i = 0;
		try {
			i = 10;
		} catch (Exception e) {
			i = 20;
		}
	}
}
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
        stack=1, locals=3, args_size=1
        0: iconst_0
        1: istore_1
        2: bipush 10
        4: istore_1
        5: goto 12
        8: astore_2
        9: bipush 20
		11:istore_1
        12:return
    Exception table:
		from to target type
		   2 5 8 Class java/lang/Exception
		LineNumberTable: ...
        LocalVariableTable:
		 Start Length Slot Name Signature
		     9   3   2      e    Ljava/lang/Exception;
             0   13  0      args [Ljava/lang/String;
             2   11  1      i       I
         StackMapTable: ...
        MethodParameters: ...
}             
  • 可以看到多出来一个 Exception table 的结构,[from, to) 是前闭后开的检测范围,一旦这个范围内的字节码执行出现异常,则通过 type 匹配异常类型,如果一致,进入 target 所指示行号
  • 8 行的字节码指令 astore_2 是将异常对象引用存入局部变量表的 slot 2 位置

多个 single-catch 块的情况

public class Demo3_11_2 {
    
	public static void main(String[] args) {
        int i = 0;
        try {
			i = 10;
		} catch (ArithmeticException e) {
        	i = 30;
        } catch (NullPointerException e) {
			i = 40;
		} catch (Exception e) {
			i = 50;
		}
	}
}
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
	 stack=1, locals=3, args_size=1
        0: iconst_0
        1: istore_1
        2: bipush 10
        4: istore_1
        5: goto 26
        8: astore_2
        9: bipush 30
        11: istore_1
        12: goto 26
        15: astore_2
        19: goto 26
        22: astore_2
        23: bipush 50
        25: istore_1
        26: return
	Exception table:
	  from to target type
		2 5 8 Class java/lang/ArithmeticException
        2 5 15 Class java/lang/NullPointerException
        2 5 22 Class java/lang/Exception
	LineNumberTable: ...
	LocalVariableTable:
        Start Length Slot Name Signature
          9    3     2     e 		Ljava/lang/ArithmeticException;
          16   3     2     e 		Ljava/lang/NullPointerException;
          23   3     2     e 		Ljava/lang/Exception;
          0   27     0    args		[Ljava/lang/String;
          2   25     1     i         I
    StackMapTable: ...
    MethodParameters: ...

  • 因为异常出现时,只能进入 Exception table 中一个分支,所以局部变量表 slot 2 位置被共用

multi-catch 的情况

public class Demo3_11_3 {
	public static void main(String[] args) {
		try {
			Method test = Demo3_11_3.class.getMethod("test");
			test.invoke(null);
		} catch (NoSuchMethodException | IllegalAccessException |
InvocationTargetException e) {
			e.printStackTrace();
		}
	}
    
public static void test() {
	System.out.println("ok");
	}
}

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
	  stack=3, locals=2, args_size=1
        0: ldc #2
        2: ldc #3
        4: iconst_0
        5: anewarray #4
        8: invokevirtual #5
        11: astore_1
        12: aload_1
        13: aconst_null
        14: iconst_0              
        15: anewarray #6
        18: invokevirtual #7
        21: pop
        22: goto 30
        25: astore_1
        26: aload_1
        27: invokevirtual #11 // e.printStackTrace:()V
        30: return
	Exception table:
		from to target type
			0 22 25 Class java/lang/NoSuchMethodException
            0 22 25 Class java/lang/IllegalAccessException
            0 22 25 Class java/lang/reflect/InvocationTargetException
    LineNumberTable: ...
    LocalVariableTable:
      Start Length Slot Name Signature
          12 10     1    test Ljava/lang/reflect/Method;
          26  4     1     e   Ljava/lang/ReflectiveOperationException;
           0  31    0    args [Ljava/lang/String;
    StackMapTable: ...
  MethodParameters: ...

finally

public class Demo3_11_4 {
    public static void main(String[] args) {
		int i = 0;
		try {
			i = 10;
		} catch (Exception e) {
			i = 20;
		} finally {
			i = 30;
		}
	}
}
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
	 stack=1, locals=4, args_size=1
        0: iconst_0
        1: istore_1 // 0 -> i
        2: bipush 10 // try --------------------------------------
        4: istore_1 // 10 -> i |
        5: bipush 30 // finally |
        7: istore_1 // 30 -> i |
        8: goto 27 // return -----------------------------------
        11: astore_2 // catch Exceptin -> e ----------------------
        12: bipush 20	//
        14: istore_1	// 20-> i
        15: bipush 30 // finally |
        17: istore_1 // 30 -> i |
        18: goto 27 // return -----------------------------------
        21: astore_3 // catch any -> slot 3 ----------------------
        22: bipush 30 // finally |
        24: istore_1 // 30 -> i |
        25: aload_3 // <- slot 3 |
        26: athrow // throw ------------------------------------
        27: return
	Exception table:
     from to target type
    	2 5 11 Class java/lang/Exception
    	2 5 21 any // 剩余的异常类型,比如 Error
    	11 15 21 any // 剩余的异常类型,比如 Error
    LineNumberTable: ...
    LocalVariableTable:
     Start Length Slot Name Signature
        12 3 2 e Ljava/lang/Exception;
        0 28 0 args [Ljava/lang/String;
        2 26 1 i I
    StackMapTable: ...
  MethodParameters: ...

可以看到 finally 中的代码被复制了 3 份,分别放入 try 流程,catch 流程以及 catch 剩余的异常类型流 程

练习

finally 出现了 return

public class Demo3_12_2 {
	public static void main(String[] args) {
		int result = test();
		System.out.println(result);
	}
	public static int test() {
		try {
			return 10;
		} finally {
			return 20;
		}
	}
}
public static int test();
	descriptor: ()I
	flags: ACC_PUBLIC, ACC_STATIC
	Code:
	  stack=1, locals=2, args_size=0
		0: bipush 10 // <- 10 放入栈顶
		2: istore_0	 // 10 -> slot 0 (从栈顶移除了)
        3: bipush 20 // <- 20 放入栈顶
            顶
        5: ireturn // 返回栈顶 int(20)
        6: astore_1 // catch any -> slot 1
        7: bipush 20 // <- 20 放入栈顶
        9: ireturn // 返回栈顶 int(20)
	Exception table:
		from to target type
			0 3 6 any
	LineNumberTable: ...
	StackMapTable: .
  • 由于 finally 中的 ireturn 被插入了所有可能的流程,因此返回结果肯定以 finally 的为准
  • 跟上例中的 finally 相比,发现没有 athrow 了,这告诉我们:如果在 finally 中出现了 return,会吞掉异常
public class Demo3_12_1 { 
    public static void main(String[] args) { 
        int result = test(); 
        System.out.println(result);
}
    public static int test() { 
        try { 
            int i = 1/0; return 10;
        } finally { 
            return 20;
        } 
    }
}

finally对返回值的影响

public class Demo3_12_2 {
    public static void main(String[] args) {
        int result = test();
        System.out.println(result);
	}
	public static int test() {
		int i = 10;
		try {
			return i;
		} finally {
			i = 20;
		}
	}
}
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=3, args_size=0
        0: bipush 10 // <- 10 放入栈顶
        2: istore_0 // 10 -> i
        3: iload_0 // <- i(10)
        4: istore_1 // 10 -> slot 1,暂存至 slot 1,目的是为了固定返回值
        5: bipush 20 // <- 20 放入栈顶
        7: istore_0 // 20 -> i
        8: iload_1 // <- slot 1(10) 载入 slot 1 暂存的值
        9: ireturn // 返回栈顶的 int(10)
        10: astore_2
        11: bipush 20
        13: istore_0
        14: aload_2
        15: athrow
    Exception table:
	  from to target type
    	 3 5 10 any
    LineNumberTable: ...
    LocalVariableTable:
    	Start Length Slot Name Signature
   			 3 13 0 i I
	StackMapTable: ...

synchronized

public class Demo3_13 {
    public static void main(String[] args) {
    	Object lock = new Object();
        synchronized (lock) {
			System.out.println("ok");
		}
	}
}
public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
	Code:
	stack=2, locals=4, args_size=1
        0: new #2 // new Object
        3: dup	//从堆中复制一份引用到操作数栈
        4: invokespecial #1 // invokespecial <init>:()V
        7: astore_1 // lock引用 -> lock
        8: aload_1 // <- lock (synchronized开始)
        9: dup		//复制对象,用于加锁和解锁
        10: astore_2 // lock引用 -> slot 2
        11: monitorenter // monitorenter(lock引用)  加锁
        12: getstatic #3 // <- System.out
        15: ldc   #4 // <- "ok"
        17: invokevirtual #5 //invokevirtual println:(Ljava/lang/String;)v
        20: aload_2 // <- slot 2(lock引用)
        21: monitorexit // monitorexit(lock引用)  解锁
        22: goto 30
        25: astore_3 // any -> slot 3
        26: aload_2 // <- slot 2(lock引用)
        27: monitorexit // monitorexit(lock引用)
        28: aload_3
        29: athrow
        30: return
	Exception table:
		from to target type
          12 22 25 any
          25 28 25 any
    LineNumberTable: ...
    LocalVariableTable:
		Start Length Slot Name Signature
            0 31 0 args [Ljava/lang/String;
            8 23 1 lock Ljava/lang/Object;
	StackMapTable: ...
  MethodParameters: ...

注意:方法级别的 synchronized 不会在字节码指令中有所体现=


编译器处理

默认构造器

​ 所谓的语法糖,其实就是指 java 编译器把 *.java 源码编译为 *.class 字节码的过程中,自动生成 和转换的一些代码,主要是为了减轻程序员的负担,算是 java 编译器给我们的一个额外福利

​ 注意,以下代码的分析,借助了 javap 工具,idea 的反编译功能,idea 插件 jclasslib 等工具。另外, 编译器转换的结果直接就是 class 字节码,只是为了便于阅读,给出了 几乎等价的 java 源码方式,并不是编译器还会转换出中间的 java 源码,切记。

public class Candy1 {
}

编译成class后的代码:

public class Candy1 {
	// 这个无参构造是编译器帮助我们加上的
	public Candy1() {
		super(); // 即调用父类 Object 的无参构造方法,即调用java/lang/Object."<init>":()V
	}
}

自动拆装箱

这个特性是 JDK 5 开始加入的, 代码片段1 :

public class Candy2 {
	public static void main(String[] args) {
		Integer x = 1;
		int y = x;
	}
}

这段代码在 JDK 5 之前是无法编译通过的,必须改写为 代码片段2 :

public class Candy2 {
	public static void main(String[] args) {
		Integer x = Integer.valueOf(1);
		int y = x.intValue();
	}
}

显然之前版本的代码太麻烦了,需要在基本类型和包装类型之间来回转换(尤其是集合类中操作的都是包装类型),因此这些转换的事情在JDK 5以后都由编译器在编译阶段完成。即代码片段1都会在编译阶段被转换为代码片段2。

泛型集合取值

泛型也是在JDK 5开始加入的特性,但 java 在编译泛型代码后会执行泛型擦除的动作,即泛型信息在编译为字节码之后就丢失了,实际的类型都当做了 Object 类型来处理

public class Candy3 {
	public static void main(String[] args) {
		List<Integer> list = new ArrayList<>();
		list.add(10); // 实际调用的是 List.add(Object e)
		Integer x = list.get(0); // 实际调用的是 Object obj = List.get(int index);
	}
}

所以在取值时,编译器真正生成的字节码中,还要额外做一个类型转换的操作:

// 需要将 Object 转为 Integer
Integer x = (Integer)list.get(0);

如果前面的 x 变量类型修改为 int 基本类型那么最终生成的字节码是:

// 需要将 Object 转为 Integer, 并执行拆箱操作
int x = ((Integer)list.get(0)).intValue();

还好这些麻烦事都不用自己做。

擦除的是字节码上的泛型信息,可以看到 LocalVariableTypeTable 仍然保留了方法参数泛型的信息

    7: astore_1
    8: aload_1
    9: bipush 10
    11: invokestatic #4 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
    14: invokeinterface #5, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z ,可以看到这里泛型已经变为了Object
    19: pop
    20: aload_1
    21: iconst_0
    22: invokeinterface #6, 2 // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
    27: checkcast #7 // class java/lang/Integer
    30: astore_2
    31: return
  LineNumberTable:
    line 8: 0
    line 9: 8
    line 10: 20
    line 11: 31
  LocalVariableTable:
    Start Length Slot Name Signature
		0 32 0 args [Ljava/lang/String;
		8 24 1 list Ljava/util/List;
	LocalVariableTypeTable:
	Start Length Slot Name Signature
		8 24 1 list Ljava/util/List<Ljava/lang/Integer;>;

使用反射,仍然能够获得这些信息:

public Set<Integer> test(List<String> list, Map<Integer, Object> map) {
}
Method test = Candy3.class.getMethod("test", List.class, Map.class);
Type[] types = test.getGenericParameterTypes();
for (Type type : types) {
	if (type instanceof ParameterizedType) {
		ParameterizedType parameterizedType = (ParameterizedType) type;
		System.out.println("原始类型 - " + parameterizedType.getRawType());
		Type[] arguments = parameterizedType.getActualTypeArguments();
		for (int i = 0; i < arguments.length; i++) {
			System.out.printf("泛型参数[%d] - %s\n", i, arguments[i]);
		}
	}
}

输出:

原始类型 - interface java.util.List
泛型参数[0] - class java.lang.String
原始类型 - interface java.util.Map
泛型参数[0] - class java.lang.Integer
泛型参数[1] - class java.lang.Object

可变参数

可变参数也是 JDK 5 开始加入的新特性: 例如:

public class Candy4 {
	public static void foo(String... args) {
		String[] array = args; // 直接赋值
		System.out.println(array);
	}
	public static void main(String[] args) {
		foo("hello", "world");
	}
}

可变参数String... args其实是一个 String[] args ,从代码中的赋值语句中就可以看出来。 同 样 java 编译器会在编译期间将上述代码变换为:

public class Candy4 {
	public static void foo(String[] args) {
		String[] array = args; // 直接赋值
		System.out.println(array);
	}
	public static void main(String[] args) {
		foo(new String[]{"hello", "world"});
	}
}

注意:如果调用了 foo() 则等价代码为 foo(new String[]{}) ,创建了一个空的数组,而不会传递 null 进去

foreach循环

仍是JDK 5开始引入的语法糖,数组的循环:

public class Candy5_1 {
	public static void main(String[] args) {
		int[] array = {1, 2, 3, 4, 5}; // 数组赋初值的简化写法也是语法糖哦
		for (int e : array) {
			System.out.println(e);
		}
	}
}

优化:

public class Candy5_1 {
    public Candy5_1() {
    }
	public static void main(String[] args) {
		int[] array = new int[]{1, 2, 3, 4, 5};
		for(int i = 0; i < array.length; ++i) {
			int e = array[i];
			System.out.println(e);
		}
	}
}

而集合的循环:

public class Candy5_2 {
	public static void main(String[] args) {
		List<Integer> list = Arrays.asList(1,2,3,4,5);
		for (Integer i : list) {
			System.out.println(i);
		}
	}
}

实际被编译器转换为对迭代器的调用:

public class Candy5_2 {
    public Candy5_2() {
    }
	public static void main(String[] args) {
		List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
		Iterator iter = list.iterator();
		while(iter.hasNext()) {
			Integer e = (Integer)iter.next();
			System.out.println(e);
		}
	}
}

注意: foreach 循环写法,能够配合数组,以及所有实现了 Iterable 接口的集合类一起使用,其 中 Iterable 用来获取集合的迭代器( Iterator )

switch字符串

JDK 7 开始,switch 可以作用于字符串和枚举类,这个功能其实也是语法糖,例如:

public class Candy6_1 {
	public static void choose(String str) {
		switch (str) {
			case "hello": {
                System.out.println("h");
			}
			case "world": {
				System.out.println("w");
			break;
			}
		}
	}
}

注意: switch 配合 String 和枚举使用时,变量不能为nul,因为语法糖中使用到了equal,如果为null,会出现空指针异常

会被编译器转换为:

public class Candy6_1 {
    public Candy6_1() {
    }
    public static void choose(String str) {
   		byte x = -1;
    	switch(str.hashCode()) {
    		case 99162322: // hello 的 hashCode
    		if (str.equals("hello")) {
    			x = 0;
    		}
    		break;
   		 	case 113318802: // world 的 hashCode
    		if (str.equals("world")) {
   			 	x = 1;
    		}
    	}
        
    	switch(x) {
    		case 0:
   			 	System.out.println("h");
    			break;
    		case 1:
    			System.out.println("w");
    	}
	}
}

可以看到,执行了两遍 switch,第一遍是根据字符串的 hashCode 和 equals 将字符串的转换为相应 byte 类型,第二遍才是利用 byte 执行进行比较。 同时输出语句是在第二个switch中,这样做是为了让两个逻辑解耦,使代码更加简单和高效。

为什么第一遍时必须既比较 hashCode,又利用 equals 比较呢?hashCode 是为了提高效率,减少可 能的比较;而 equals 是为了防止 hashCode 冲突,例如 BM 和 C. 这两个字符串的hashCode值都是 2123 ,如果有如下代码:

public class Candy6_2 {
	public static void choose(String str) {
		switch (str) {
			case "BM": {
				System.out.println("h");
				break;
			}
            case "C": {
                System.out.println("w");
				break;
            }
        }
    }
}

会被编译器转换为:

public class Candy6_2 {
    public Candy6_2() {
    }
    public static void choose(String str) {
    	byte x = -1;
    	switch(str.hashCode()) {
    		case 2123: // hashCode 值可能相同,需要进一步用 equals 比较
    		if (str.equals("C.")) {
    			x = 1;
    		} else if (str.equals("BM")) {
    			x = 0;
   			}
   		default:
    		switch(x) {
    			case 0:
    				System.out.println("h");
    				break;
    			case 1:
    				System.out.println("w");
    			}
			}
		}
}

switch枚举

switch 枚举的例子,原始代码:

enum Sex {
	MALE, FEMALE
}
public class Candy7 {
	public static void foo(Sex sex) {
		switch (sex) {
			case MALE:
				System.out.println("男"); break;
			case FEMALE:
				System.out.println("女"); break;
		}
	}
}

转换后代码:

public class Candy7 {
    /**
	* 定义一个合成类(仅 jvm 使用,对我们不可见)
    * 用来映射枚举的 ordinal 与数组元素的关系
    * 枚举的 ordinal 表示枚举对象的序号,从 0 开始
    * 即 MALE 的 ordinal()=0,FEMALE 的 ordinal()=1
    */
	static class $MAP {
	// 数组大小即为枚举元素个数,里面存储case用来对比的数字
    static int[] map = new int[2];
    static {
    	map[Sex.MALE.ordinal()] = 1;
    	map[Sex.FEMALE.ordinal()] = 2;
    }
}
	public static void foo(Sex sex) {
		int x = $MAP.map[sex.ordinal()];
		switch (x) {
			case 1:
				System.out.println("男");
				break;
			case 2:
				System.out.println("女");
				break;
		}
	}
}

枚举类

JDK 7 新增了枚举类,以前面的性别枚举为例:

enum Sex {
	MALE, FEMALE
}

转换后代码:

public final class Sex extends Enum<Sex> {
    public static final Sex MALE;
    public static final Sex FEMALE;
    private static final Sex[] $VALUES;
    static {
        MALE = new Sex("MALE", 0);
        FEMALE = new Sex("FEMALE", 1);
        $VALUES = new Sex[]{MALE, FEMALE};
    }
    /**
     * Sole constructor. Programmers cannot invoke this constructor.
     * It is for use by code emitted by the compiler in response to
     * enum type declarations. 
     * @param name - The name of this enum constant, which is the identifier
     * used to declare it.
     * @param ordinal - The ordinal of this enumeration constant (its position
     * in the enum declaration, where the initial constant is
    assigned
     */
    private Sex(String name, int ordinal) {
        super(name, ordinal);
    }
    public static Sex[] values() {
        return $VALUES.clone();
    }
    public static Sex valueOf(String name) {
        return Enum.valueOf(Sex.class, name);
    }
}

try-with-resources

JDK 7 开始新增了对需要关闭的资源处理的特殊语法 try-with-resources

try(资源变量 = 创建资源对象){
    
} catch( ) {
    
}

其中资源对象需要实现 AutoCloseable 接口,例如 InputStream 、 OutputStream 、 Connection 、 Statement 、 ResultSet 等接口都实现了 AutoCloseable ,使用 try-withresources 可以不用写 finally 语句块,编译器会帮助生成关闭资源代码,例如:

public class Candy9 {
	public static void main(String[] args) {
		try(InputStream is = new FileInputStream("d:\\1.txt")) {
			System.out.println(is);
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
}

会被转换为:

public class Candy9 {
    public Candy9() {
    }

    public static void main(String[] args) {
        try {
            InputStream is = new FileInputStream("d:\\1.txt");
            Throwable t = null;
            try {
                System.out.println(is);
            } catch (Throwable e1) {
                // t 是我们代码出现的异常
                t = e1;
                throw e1;
            } finally {
                // 判断了资源不为空
                if (is != null) {
                    // 如果我们代码有异常
                    if (t != null) {
                        try {
                            is.close();
                        } catch (Throwable e2) {
                            // 如果 close 出现异常,作为被压制异常添加
                            t.addSuppressed(e2);
                        }
                    } else {
                        // 如果我们代码没有异常,close 出现的异常就是最后 catch 块中的 e
                        is.close();
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

为什么要设计一个 addSuppressed(Throwable e) (添加被压制异常)的方法呢?是为了防止异常信 息的丢失(想想 try-with-resources 生成的 fianlly 中如果抛出了异常):

public class Test6 {
    public static void main(String[] args) {
        try (MyResource resource = new MyResource()) {
            int i = 1/0;
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
class MyResource implements AutoCloseable {
    public void close() throws Exception {
        throw new Exception("close 异常");
    }
}

输出:

java.lang.ArithmeticException: / by zero
	at test.Test6.main(Test6.java:7)
	Suppressed: java.lang.Exception: close 异常
		at test.MyResource.close(Test6.java:18)
		at test.Test6.main(Test6.java:6)

方法重写时的桥接方法

我们都知道,方法重写时对返回值分两种情况:

  • 父子类的返回值完全一致
  • 子类返回值可以是父类返回值的子类(比较绕口,见下面的例子)
class A {
    public Number m() {
        return 1;
    }
}
class B extends A {
    @Override
	// 子类 m 方法的返回值是 Integer 是父类 m 方法返回值 Number 的子类
    public Integer m() {
        return 2;
    }
}

对于子类,java 编译器会做如下处理:

class B extends A {
    public Integer m() {
        return 2;
    }
    // 此方法才是真正重写了父类 public Number m() 方法
    public synthetic bridge Number m() {
		// 调用 public Integer m()
        return m();
    }
}

其中桥接方法比较特殊,仅对 java 虚拟机可见,并且与原来的 public Integer m() 没有命名冲突,可以 用下面反射代码来验证:

for (Method m : B.class.getDeclaredMethods()) {
	System.out.println(m);
}

会输出:

public java.lang.Integer test.candy.B.m()
public java.lang.Number test.candy.B.m()

匿名内部类

源代码:

public class Candy11{
    public static void main(String[] args) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("ok");
            }
        };
    }
}

转换后的代码:

// 额外生成的类
final class Candy11$1 implements Runnable {
	Candy11$1() {
	}
	public void run() {
		System.out.println("ok");
	}
}
public class Candy11 {
	public static void main(String[] args) {
		Runnable runnable = new Candy11$1();
	}
}

引用局部变量的匿名内部类,源代码:

public class Candy11 {
	public static void test(final int x) {
		Runnable runnable = new Runnable() {
			@Override
			public void run() {
				System.out.println("ok:" + x);
			}
		};
	}
}

转换后代码:

// 额外生成的类
final class Candy11$1 implements Runnable {
	int val$x;
	Candy11$1(int x) {
		this.val$x = x;
	}	
	public void run() {
		System.out.println("ok:" + this.val$x);
	}
}
public class Candy11 {
    public static void test(final int x) {
        Runnable runnable = new Candy11$1(x);
    } 
}

注意: 这同时解释了为什么匿名内部类引用局部变量时,局部变量必须是 final 的:因为在创建 Candy11$1 对象时,将 x 的值赋值给了 Candy11$1 对象的 val$x属 性 , 不 应 该 再 发 生 变 了 , 如 果 变 , 那 么 x 属性没有机会再跟着一起变化

类加载阶段

加载

  • 将类的字节码载入方法区中,内部采用 C++ 的 instanceKlass 描述 java 类,它的重要 field 有:
    • _java_mirror 即 java 的类镜像,例如对 String 来说,就是 String.class,作用是把 klass 暴 露给 java 使用 (java对象不能直接访问Klass对象,需要先访问String.class,然后通过class去访问klass,他们之间互相持有对方的指针)
    • _super 即父类
    • _fields 即成员变量
    • _methods 即方法
    • _constants 即常量池
    • _class_loader 即类加载器
    • _vtable 虚方法表
    • _itable 接口方法表
  • 如果这个类还有父类没有加载,先加载父类
  • 加载和链接可能是交替运行的

注意:

  • instanceKlass 这样的【元数据】是存储在方法区(1.8 后的元空间内),但 _java_mirror 是存储在堆中
  • 可以通过前面介绍的 HSDB 工具查看

image-20210210123751069

  • 一个实例对象的对象头是16个字节,前8个字节指向了它实例化的类,然后通过class去元空间中找到对应的方法信息
  • 元空间中的instanceKlass和堆中的类对象他们之间互相持有彼此的地址

链接

验证

验证类是否符合JVM规范,安全性检查

例:修改类的字节码信息,将魔数部分,cafebabe改为cafebaba,在控制台执行

E:\git\jvm\out\production\jvm>java cn.itcast.jvm.t5.HelloWorld
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.ClassFormatError: Incompatible magic value
3405691578 in class file cn/itcast/jvm/t5/HelloWorld
        at java.lang.ClassLoader.defineClass1(Native Method)
        at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
        at
java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
        at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
        at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
        at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
        at java.net.URLClassLoader$1.run(URLClassLoader.java:362)
        at java.security.AccessController.doPrivileged(Native Method)
        at java.net.URLClassLoader.findClass(URLClassLoader.java:361)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
        at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
        at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
        at sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)

准备

为 static 变量分配空间,设置默认值

  • static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于 _java_mirror 末尾
  • static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
  • 如果static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
  • 如果 static 变量是 final 的,但属于引用类型(new),那么赋值也会在初始化阶段完成

解析

作用:将其在常量池中的符号引用替换成直接其在内存中的直接引用。

package cn.itcast.jvm.t3.load;
/**
 * 解析的含义
 */
public class Load2 {
    public static void main(String[] args) throws ClassNotFoundException,
            IOException {
        ClassLoader classloader = Load2.class.getClassLoader();
		// loadClass 方法不会导致类的解析和初始化
        Class<?> c = classloader.loadClass("cn.itcast.jvm.t3.load.C");
		// new C();	会导致类的解析和初始化
        System.in.read();
    }
}
class C {
    D d = new D();
}
class D {
}

初始化

<cinit>()v方法

初始化即调用<cinit>()v,虚拟机会保证这个类的『构造方法』的线程安全

发生的时机

概括得说,类初始化是【懒惰的】

  • main 方法所在的类,总会被首先初始化
  • 首次访问这个类的静态变量或静态方法时
  • 子类初始化,如果父类还没初始化,会引发
  • 子类访问父类的静态变量,只会触发父类的初始化
  • Class.forName
  • new 会导致初始化

不会导致类初始化的情况

  • 访问类的 static final 静态常量(基本类型和字符串)不会触发初始化
  • 类对象.class不会触发初始化
  • 创建该类的数组不会触发初始化
  • 类加载器的loadClass方法
  • Class.forName的参数2为false时
public class Load3 {
    static {
        System.out.println("main init");
    }

    public static void main(String[] args) throws ClassNotFoundException {
        // 1. 静态常量(基本类型和字符串)不会触发初始化
        //      System.out.println(B.b);
        // 2. 类对象.class 不会触发初始化
        //       System.out.println(B.class);
        // 3. 创建该类的数组不会触发初始化
        //       System.out.println(new B[0]);
        // 4. 不会初始化类 B,但会加载 B、A
        //      ClassLoader cl = Thread.currentThread().getContextClassLoader();
        //      cl.loadClass("cn.itcast.jvm.t3.B");
        // 5. 不会初始化类 B,但会加载 B、A
        //       ClassLoader c2 = Thread.currentThread().getContextClassLoader();
        //       Class.forName("cn.itcast.jvm.t3.B", false, c2);
        // 1. 首次访问这个类的静态变量或静态方法时
        //     System.out.println(A.a);
        // 2. 子类初始化,如果父类还没初始化,会引发
        //     System.out.println(B.c);
        // 3. 子类访问父类静态变量,只触发父类初始化
        //     System.out.println(B.a);
        // 4. 会初始化类 B,并先初始化类 A
        //     Class.forName("cn.itcast.jvm.t3.B");
    }
}

class A {
    static int a = 0;

    static {
        System.out.println("a init");
    }
}

class B extends A {
    final static double b = 5.0;
    static boolean c = false;

    static {
        System.out.println("b init");
    }
}

典型应用:

public final class Singleton {
    private Singleton() { }
	// 内部类中保存单例
	private static class LazyHolder {
	static final Singleton INSTANCE = new Singleton();
	}
	// 第一次调用 getInstance 方法,才会导致内部类加载和初始化其静态成员
	public static Singleton getInstance() {
		return LazyHolder.INSTANCE;
	}
}

以上的实现特点是:

  • 懒惰实例化
  • 初始化时的线程安全是有保障的

类加载器

JVM预定义的三种类型类加载器:

  • 启动(Bootstrap)类加载器:是用本地代码实现的类装入器,它负责将 <Java_Runtime_Home>/lib下面的类库加载到内存中(比如rt.jar)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。

  • 标准扩展(Extension)类加载器:是由 Sun 的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将< Java_Runtime_Home >/lib/ext或者由系统变量 java.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。

  • 系统(System)类加载器:是由 Sun 的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。它负责将系统类路径(CLASSPATH)中指定的类库加载到内存中。开发者可以直接使用系统类加载器。

  • 用户自定义(CustomClassLoader)加载器:java编写,用户自定义的类加载器,可加载指定路径的class文件

image-20210210172542831

名称 加载哪的类 说明
Bootstrap ClassLoader JAVA_HOME/jre/lib 无法直接访问
Extension ClassLoader JAVA_HOME/jre/lib/ext 上级为 Bootstrap,显示为 null
Application ClassLoader classpath 上级为 Extension
自定义类加载器 自定义 上级为 Application

启动类加载器

用 Bootstrap 类加载器加载类:

package cn.itcast.jvm.t3.load;

public class F {
    static {
        System.out.println("bootstrap F init");
    }
}

执行

package cn.itcast.jvm.t3.load;
public class Load5_1 {
	public static void main(String[] args) throws 	ClassNotFoundException {
	Class<?> aClass = Class.forName("cn.itcast.jvm.t3.load.F");
	System.out.println(aClass.getClassLoader());
	}
}

输出

E:\git\jvm\out\production\jvm>java -Xbootclasspath/a:.cn.itcast.jvm.t3.load.Load5_1
bootstrap F init
null
  • -Xbootclasspath 表示设置 bootclasspath
  • 其中 /a:. 表示将当前目录追加至 bootclasspath 之后
  • 可以用这个办法替换核心类
    • java -Xbootclasspath:<new bootclasspath>
    • java -Xbootclasspath/a:<追加路径>
    • java -Xbootclasspath/p:<追加路径>

拓展类加载器

public class G {
	static {
	System.out.println("classpath G init");
	}
}

执行

package cn.itcast.jvm.t3.load;
public class Load5_1 {
	public static void main(String[] args) throws 	ClassNotFoundException {
	Class<?> aClass = Class.forName("cn.itcast.jvm.t3.load.G");
	System.out.println(aClass.getClassLoader());
	}
}

输出

classpath G init
sun.misc.Launcher$AppClassLoader@18b4aac2

写一个同名类

package cn.itcast.jvm.t3.load;

public class G {
	static {
		System.out.println("ext G init");
	}
}

打个jar包

E:\git\jvm\out\production\jvm>jar -cvf my.jar cn/itcast/jvm/t3/load/G.class
已添加清单
正在添加: cn/itcast/jvm/t3/load/G.class(输入 = 481) (输出 = 322)(压缩了 33%)

将 jar 包拷贝到 JAVA_HOME/jre/lib/ext

重新执行 Load5_2

输出

ext G init
sun.misc.Launcher$ExtClassLoader@29453f44

双亲委派机制

所谓的双亲委派,就是指调用类加载器的 loadClass 方法时,查找类的规则

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 1. 检查该类是否已经加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    // 2. 有上级的话,委派上级 loadClass
                    c = parent.loadClass(name, false);
                } else {
                    // 3. 如果没有上级了(ExtClassLoader),则委派BootstrapClassLoader
                        c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
            }
            if (c == null) {
                long t1 = System.nanoTime();
                // 4. 每一层找不到,调用 findClass 方法(每个类加载器自己扩展)来加载
                c = findClass(name);
                // 5. 记录耗时
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

例如:

public class Load4 {
    public static void main(String[] args) throws ClassNotFoundException {
        Class<?> aClass = Load4.class.getClassLoader()
                .loadClass("cn.itcast.jvm.t3.load.H");
        System.out.println(aClass.getClassLoader());
    }
}

执行流程为:

  1. sun.misc.Launcher$AppClassLoader //1 处, 开始查看已加载的类,结果没有
  2. sun.misc.Launcher$AppClassLoader // 2 处,委派上级 sun.misc.Launcher$ExtClassLoader.loadClass()
  3. sun.misc.Launcher$ExtClassLoader // 1 处,查看已加载的类,结果没有
  4. 查找 BootstrapClassLoader 类加载器
  5. BootstrapClassLoader 是在 JAVA_HOME/jre/lib 下找 H 这个类,显然没有
  6. sun.misc.Launcher$ExtClassLoader // 4 处,调用自己的 findClass 方法,是在 JAVA_HOME/jre/lib/ext 下找 H 这个类,显然没有,回到 sun.misc.Launcher$AppClassLoader 的 // 2 处
  7. 继续执行到 sun.misc.Launcher$AppClassLoader // 4 处,调用它自己的 findClass 方法,在 classpath 下查找,找到了

线程上下文类加载器

我们在使用 JDBC 时,都需要加载 Driver 驱动,不知道你注意到没有,不写

Class.forName("com.mysql.jdbc.Driver")

也是可以让 com.mysql.jdbc.Driver 正确加载的,你知道是怎么做的吗?

让我们追踪一下源码:

public class DriverManager {
    // 注册驱动的集合
    private final static CopyOnWriteArrayList<DriverInfo> registeredDrivers = new CopyOnWriteArrayList<>();
    
    // 初始化驱动
    static {
    	loadInitialDrivers();
    	println("JDBC DriverManager initialized");
    }

先不看别的,看看 DriverManager 的类加载器:

System.out.println(DriverManager.class.getClassLoader());

打印 null,表示它的类加载器是 Bootstrap ClassLoader,会到 JAVA_HOME/jre/lib 下搜索类,但 JAVA_HOME/jre/lib 下显然没有 mysql-connector-java-5.1.47.jar 包,这样问题来了,在 DriverManager 的静态代码块中,怎么能正确加载 com.mysql.jdbc.Driver 呢?

继续看 loadInitialDrivers() 方法:

private static void loadInitialDrivers() {
    String drivers;
    try {
        drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
            public String run() {
                return System.getProperty("jdbc.drivers");
            }
        });
    } catch (Exception ex) {
        drivers = null;
    }

    // 1)使用 ServiceLoader 机制加载去当,即 SPI
    AccessController.doPrivileged(new PrivilegedAction<Void>() {
        public Void run() {

            ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
            Iterator<Driver> driversIterator = loadedDrivers.iterator();

            try{
                while(driversIterator.hasNext()) {
                    driversIterator.next();
                }
            } catch(Throwable t) {
                // Do nothing
            }
            return null;
        }
    });

    println("DriverManager.initialize: jdbc.drivers = " + drivers);

    // 2) 使用 jdbc.drivers 定义的驱动名加载驱动
    if (drivers == null || drivers.equals("")) {
        return;
    }
    String[] driversList = drivers.split(":");
    println("number of Drivers:" + driversList.length);
    for (String aDriver : driversList) {
        try {
            println("DriverManager.Initialize: loading " + aDriver);
            // 这里的 ClassLoader.getSystemClassLoader() 就是应用程序类加载器,从这里可以看出打破了双亲委派机制,按道理来说应该使用bootstrap加载器来加载该类的,因为和driver相关的类在java.sql.DriverManager包下
            Class.forName(aDriver, true,
                          ClassLoader.getSystemClassLoader());
        } catch (Exception ex) {
            println("DriverManager.Initialize: load failed: " + ex);
        }
    }
}

先看 2)发现它最后是使用 Class.forName 完成类的加载和初始化,关联的是应用程序类加载器,因此可以顺利完成类加载,从这里可以看出打破了双亲委派机制,按道理来说应该使用bootstrap加载器来加载该类的,因为和driver相关的类在

再看 1)它就是大名鼎鼎的 Service Provider Interface (SPI)

约定如下,在 jar 包的 META-INF/services 包下,以接口全限定名名为文件,文件内容是实现类名称

image-20210211010109271

这样就可以使用

ServiceLoader<接口类型> allImpls = ServiceLoader.load(接口类型.class);
Iterator<接口类型> iter = allImpls.iterator();
while(iter.hasNext()) {
	iter.next();
}

来得到实现类,体现的是【面向接口编程+解耦】的思想,在下面一些框架中都运用了此思想:

  • JDBC
  • Servlet 初始化器
  • Spring 容器
  • Dubbo(对 SPI 进行了扩展)

接着看ServiceLoader.load方法:

public static <S> ServiceLoader<S> load(Class<S> service) {
    // 获取线程上下文类加载器
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

线程上下文类加载器是当前线程使用的类加载器,默认就是应用程序类加载器,它内部又是由 Class.forName 调用了线程上下文类加载器完成类加载,具体代码在 ServiceLoader 的内部类 LazyIterator 中:

private S nextService() {
    if (!hasNextService())
        throw new NoSuchElementException();
    String cn = nextName;
    nextName = null;
    Class<?> c = null;
    try {
        c = Class.forName(cn, false, loader);
    } catch (ClassNotFoundException x) {
        fail(service,
             "Provider " + cn + " not found");
    }
    if (!service.isAssignableFrom(c)) {
        fail(service,
             "Provider " + cn  + " not a subtype");
    }
    try {
        S p = service.cast(c.newInstance());
        providers.put(cn, p);
        return p;
    } catch (Throwable x) {
        fail(service,
             "Provider " + cn + " could not be instantiated",
             x);
    }
    throw new Error();          // This cannot happen
}

自定义类加载器

问问自己,什么时候需要自定义类加载器

  • 1)想加载非 classpath 随意路径中的类文件
  • 2)都是通过接口来使用实现,希望解耦时,常用在框架设计
  • 3)这些类希望予以隔离,不同应用的同名类都可以加载,不冲突,常见于 tomcat 容器

步骤:

  1. 继承 ClassLoader 父类
  2. 要遵从双亲委派机制,重写 findClass 方法 注意不是重写 loadClass 方法,否则不会走双亲委派机制
  3. 读取类文件的字节码
  4. 调用父类的 defineClass 方法来加载类
  5. 使用者调用该类加载器的 findClass方法
public class MyClassLoader extends ClassLoader {


    public static void main(String[] args) throws ClassNotFoundException {
        MyClassLoader1 classLoader1 = new MyClassLoader1();
        Class<?> c1 = classLoader1.findClass("MapImpl1");
        Class<?> c2 = classLoader1.findClass("MapImpl1");
        System.out.println(c1 == c2);   // true

        MyClassLoader1 classLoader2 = new MyClassLoader1();
        Class<?> c3 = classLoader2.findClass("MapImpl1");
        //类加载器对象不是同一个类
        System.out.println(c1 == c3);   // false
    }

}

class MyClassLoader1 extends ClassLoader {

    //类加载器的对象不是同一个
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String path = "e:\\myclasspath\\" + name + ".class";

        ByteArrayOutputStream os = new ByteArrayOutputStream();
        try {
            Files.copy(Paths.get(path), os);

            //得到字节数组
            byte[] bytes = os.toByteArray();

            //byte[] => *.class
            return defineClass(name, bytes, 0, bytes.length);
        } catch (IOException e) {
            e.printStackTrace();
            throw new ClassNotFoundException("类文件未找到", e);
        }

    }
}

运行期优化

即时编译

分层编译

(TieredCompilation)

逃逸分析:https://blog.csdn.net/hollis_chuang/article/details/80922794

例:

public class JIT1 {
    public static void main(String[] args) {
        for (int i = 0; i < 200; i++) {
            long start = System.nanoTime();
            for (int j = 0; j < 1000; j++) {
                new Object();
            }
            long end = System.nanoTime();
            System.out.printf("%d\t%d\n",i,(end - start));
        }
    }
}

结果:

0	57000
1	20600
2	17900
3	17600
4	16800
5	17500
6	...
37	17700
38	17700
39	17700
40	...
62	18300
63	18200
64	18100
65	9800
66	5900
67	6100
68	6000
69	....
155	6600
156	6800
157	6000
158	6300
159	13900
160	5700
161	6000
162	6600
163	6000
164	5300
165	6000
166	6300
167	38200
168	10000
169	300
170	300
171	300
172	300
173	300
174	300
175	...
198	300
199	300

原因分析:

JVM 将执行状态分成了 5 个层次:

  • 0 层,解释执行(Interpreter)
  • 1 层,使用 C1 即时编译器编译执行(不带 profiling)
  • 2 层,使用 C1 即时编译器编译执行(带基本的 profiling)
  • 3 层,使用 C1 即时编译器编译执行(带完全的 profiling)
  • 4 层,使用 C2 即时编译器编译执行

profiling 是指在运行过程中收集一些程序执行状态的数据,例如【方法的调用次数】,【循环的 回边次数】等

即时编译器(JIT)与解释器的区别

  • 解释器是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释
  • JIT 是将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
  • 解释器是将字节码解释为针对所有平台都通用的机器码
  • JIT 会根据平台类型,生成平台特定的机器码

对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。 执行效率上简单比较一下 Interpreter < C1 < C2,总的目标是发现热点代码(hotspot名称的由来),优化之

刚才的一种优化手段称之为【逃逸分析】,发现新建的对象是否逃逸。可以使用 -XX:- DoEscapeAnalysis 关闭逃逸分析,再运行刚才的示例观察结果


方法内联

(Inlining)

private static int square(final int i) {
	return i * i;
}
System.out.println(square(9));

如果发现 square 是热点方法,并且长度不太长时,会进行内联,所谓的内联就是把方法内代码拷贝、 粘贴到调用者的位置:

System.out.println(9 * 9);

还能够进行常量折叠(constant folding)的优化:

System.out.println(81);

实验:

public class JIT2 {
    // -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining (解锁隐藏参数)打印inlining 信息
    // -XX:CompileCommand=dontinline,*JIT2.square 禁止某个方法 inlining
    // -XX:+PrintCompilation 打印编译信息
    public static void main(String[] args) {
        int x = 0;
        for (int i = 0; i < 500; i++) {
            long start = System.nanoTime();
            for (int j = 0; j < 1000; j++) {
                x = square(9);
            }
            long end = System.nanoTime();
            System.out.printf("%d\t%d\t%d\n",i,x,(end - start));
        }
    }
    private static int square(final int i) {
        return i * i;
    }
}

反射优化

import java.lang.reflect.Method;

public class Reflect1 {
    public static void foo() {
        System.out.println("foo...");
    }

    public static void main(String[] args) throws Exception {
        Method foo = Reflect1.class.getMethod("foo");
        for (int i = 0; i <= 16; i++) {
            System.out.printf("%d\t", i);
            foo.invoke(null);
        }
        System.in.read();
    }
}

foo.invoke 前面 0 ~ 15 次调用使用的是 MethodAccessor 的 NativeMethodAccessorImpl 实现

package sun.reflect;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import sun.reflect.misc.ReflectUtil;

class NativeMethodAccessorImpl extends MethodAccessorImpl {
    private final Method method;
    private DelegatingMethodAccessorImpl parent;
    private int numInvocations;

    NativeMethodAccessorImpl(Method var1) {
        this.method = var1;
    }

    public Object invoke(Object var1, Object[] var2) throws IllegalArgumentException, InvocationTargetException {
        if (++this.numInvocations > ReflectionFactory.inflationThreshold() && !ReflectUtil.isVMAnonymousClass(this.method.getDeclaringClass())) {
            MethodAccessorImpl var3 = (MethodAccessorImpl)(new MethodAccessorGenerator()).generateMethod(this.method.getDeclaringClass(), this.method.getName(), this.method.getParameterTypes(), this.method.getReturnType(), this.method.getExceptionTypes(), this.method.getModifiers());
            this.parent.setDelegate(var3);
        }

        return invoke0(this.method, var1, var2);
    }

    void setParent(DelegatingMethodAccessorImpl var1) {
        this.parent = var1;
    }

    private static native Object invoke0(Method var0, Object var1, Object[] var2);
}

当调用到第 16 次(从0开始算)时,会采用运行时生成的类代替掉最初的实现,可以通过 debug 得到 类名为 sun.reflect.GeneratedMethodAccessor1

注意 :

  • 通过查看 ReflectionFactory 源码可知 sun.reflect.noInflation 可以用来禁用膨胀(直接生成 GeneratedMethodAccessor1,但首次生成比较耗时,如果仅反射调用一次,不划算)
  • sun.reflect.inflationThreshold 可以修改膨胀阈值

内存模型

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

java内存模型

很多人将【java 内存结构】与【java 内存模型】傻傻分不清,【java 内存模型】是 Java Memory Model(JMM)的意思。 关于它的权威解释,请参考 https://download.oracle.com/otn-pub/jcp/memory_model-1.0-pfdspec-oth-JSpec/memory_model-1_0-pfd-spec.pdf?AuthParam=1562811549_4d4994cbd5b59d964cd2907ea22ca08b 简单的说,JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障

原子性

原子性在学习线程时讲过,下面来个例子简单回顾一下: 问题提出,两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

问题分析

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作。

例如对于i++而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 加法
putstatic i // 将修改后的值存入静态变量i

而对应 i-- 也是类似:

getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 减法
putstatic i // 将修改后的值存入静态变量i

image-20210211155439377

如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:

// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
getstatic i // 线程1-获取静态变量i的值 线程内i=1
iconst_1 // 线程1-准备常量1
isub // 线程1-自减 线程内i=0
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=0

但多线程下这 8 行代码可能交错运行(为什么会交错?思考一下): 出现负数的情况:

// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1

出现正数的情况:

// 假设i的初始值为0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1

解决方法

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);
}

可见性

退不出的循环

先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

public class JMM1 {
    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;
    }
}

为什么呢?分析一下:

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存

    image-20210211161628420

  2. 因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率

image-20210211161659854

  1. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读 取这个变量的值,结果永远是旧值

    image-20210211161731956

解决方法

volatile(易变关键字)

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

可见性

前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 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-- ,只能保证看到最新值,不能解决指令交错

getstatic i // 线程1-获取静态变量i的值 线程内i=0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1

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

如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也 能正确看到对 run 变量的修改了,想一想为什么?

public void println(int x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

因为println方法内部也有一个synchronized锁,要打对印流做一个同步,会打破之前的锁,以此从主存中获取值。

有序性

int num = 0;
boolean ready = false;

// 线程1 执行此方法
public void actor1(I_Result r) {
	if(ready) {
		r.r1 = num + num;
	} else {
		r.r1 = 1;
	}
}

// 线程2 执行此方法
public void actor2(I_Result r) {
	num = 2;
	ready = true;
}

有序性

诡异的结果

int num = 0;
boolean ready = false;

// 线程1 执行此方法
public void actor1(I_Result r) {
	if(ready) {
		r.r1 = num + num;
	} else {
		r.r1 = 1;
	}
}

// 线程2 执行此方法
public void actor2(I_Result r) {
	num = 2;
	ready = true;
}

I_Result 是一个对象,有一个属性 r1 用来保存结果,问,可能的结果有几种?

有同学这么分析

情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1

情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结 果为1

情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过 了)

情况4:还有可能为0,这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化。

解决方法

volatile 修饰的变量,可以禁用指令重排

有序性理解

static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...; // 较为耗时的操作
j = ...;

可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。所以,上面代码真正执行时, 既可以是

i = ...; // 较为耗时的操作
j = ...;

也可以是:

j = ...;
i = ...; // 较为耗时的操作

这种特性称之为『指令重排』,多线程下『指令重排』会影响正确性,例如著名的 double-checked locking 模式实现单例

public final class Singleton {
    private Singleton() { }
    private static Singleton INSTANCE = null;
    public static Singleton getInstance() {
		// 实例没创建,才会进入内部的 synchronized代码块
        if (INSTANCE == null) {
            synchronized (Singleton.class) {
				// 也许有其它线程已经创建实例,所以再判断一次
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

以上的实现特点是:

  • 懒惰实例化
  • 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁

但在多线程环境下,上面的代码是有问题的,INSTANCE = new Singleton() 对应的字节码为:

0: new #2 // class cn/itcast/jvm/t4/Singleton
3: dup		// 将堆中的实例的引用放到操作数栈中,并再复制一份
4: invokespecial #3 // Method "<init>":()V
7: putstatic #4 // Field
INSTANCE:Lcn/itcast/jvm/t4/Singleton;

其中4 7两步的顺序是不固定的,也许jvm会优化为:先将引用地址赋值给INSTANCE变量后,再执行构造方法,如果两个线程t1,t2按如下时间序列在执行:

时间1 t1 线程执行到 INSTANCE = new Singleton();
时间2 t1 线程分配空间,为Singleton对象生成了引用地址(0 处)
时间3 t1 线程将引用地址赋值给 INSTANCE,这时 INSTANCE != null(7 处)
时间4 t2 线程进入getInstance() 方法,发现 INSTANCE != null(synchronized块外),直接返回 INSTANCE
时间5 t1 线程执行Singleton的构造方法(4 处)

如果发生指令重排,先执行7,这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例

对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效

happens-before

happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结, 抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变 量的读可见

  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见

    static int x;
    static Object m = new Object();
    new Thread(()->{
        synchronized(m){
            x=10;
        }
    },"t1").start();
    new Thread(()->{
        synchronized(m){
            System.out.println(x);
        }
    },"t2").start();
    
  • 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见

    volatile static int x;
    
    new Thread(()->{
    	x = 10;
    },"t1").start();
    
    new Thread(()->{
    	System.out.println(x);
    },"t2").start();
    
    
  • 线程 start 前对变量的写,对该线程开始后对变量的读可见

    static int x;
    
    x = 10;
    
    new Thread(()->{
    	System.out.println(x);
    },"t2").start();
    
  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)

    static int x;
    
    Thread t1 = new Thread(()->{
    	x = 10;
    },"t1");
    t1.start();
    
    t1.join();
    System.out.println(x);
    
  • 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通 过t2.interrupted 或 t2.isInterrupted)

    static int x;
    public static void main(String[] args) {
        Thread t2 = new Thread(()->{
            while(true) {
                if(Thread.currentThread().isInterrupted()) {
                    System.out.println(x);
                    break;
                }
            }
        },"t2");
        t2.start();
        new Thread(()->{
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            x = 10;
            t2.interrupt();	//将t2线程中断
        },"t1").start();
        
        while(!t2.isInterrupted()) {
            Thread.yield();
        }
        System.out.println(x);
    }
    
  • 对变量默认值(0,false,null)的写,对其它线程对该变量的读可见

  • 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z

变量都是指成员变量或静态成员变量

CAS 与 原子类

CAS

CAS 即 Compare and Swap (比较并交换),它体现的一种乐观锁的思想,比如多个线程要对一个共享的整型变量执 行 +1 操作:

// 需要不断尝试
while(true) {
    int 旧值 = 共享变量 ; // 比如拿到了当前值 0
    int 结果 = 旧值 + 1; // 在旧值 0 的基础上增加 1 ,正确结果是 1
    /*
这时候如果别的线程把共享变量改成了 5,本线程的正确结果 1 就作废了,这时候
compareAndSwap 返回 false,重新尝试,直到:
compareAndSwap 返回 true,表示我本线程做修改的同时,别的线程没有干扰
*/
    if( compareAndSwap ( 旧值, 结果 )) {
        // 成功,退出循环
    }
}

获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。结合 CAS 和 volatile 可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。

compareAndSwap:在修改之前,将旧的值和共享变量进行比较,如果比较的结果相同那么

  • 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
  • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响

乐观锁和悲观锁

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系, 我吃亏点再重试呗。
  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁 你们都别想改,我改完了解开锁,你们才有机会。

原子操作类

juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、 AtomicBoolean等,它们底层就是采用 CAS 技术 + volatile 来实现的。

public class AtomicDemo {
    // 创建原子整数对象
    private static AtomicInteger i = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                i.getAndIncrement();    // i++
                //  i.incrementAndGet();    // ++i
            }
        });

        Thread t2 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                i.getAndDecrement(); // i--
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

synchronization

Java HotSpot 虚拟机中,每个对象都有对象头(包括 class 指针和 Mark Word)。Mark Word 平时存储这个对象的 哈希码 分代年龄 ,当加锁时,这些信息就根据情况被替换为 标记位 线程锁记录指 针重量级锁指针 线程ID 等内容

轻量级锁

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

学生(线程 A)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明没有竞争,继续上他的课。

如果这期间有其它学生(线程 B)来了,会告知(线程A)有并发访问,线程 A 随即升级为重量级锁,进入重量级锁的流程。

而重量级锁就不是那么用课本占座那么简单了,可以想象线程 A 走之前,把座位用一个铁栅栏围起来

假设有两个方法同步块,利用同一个对象加锁

static Object obj = new Object();
public static void method1() {
	synchronized( obj ) {
	// 同步块 A
	method2();
	}
}
public static void method2() {
	synchronized( obj ) {
	// 同步块 B
	}
}

每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word

线程1 对象Mark Word 线程2
访问同步块 A,把 Mark 复制到 线程 1 的锁记录 01(无锁) -
CAS 修改 Mark 为线程 1 锁记录 地址 01(无锁) -
成功(加锁) 00(轻量锁)线程 1 锁记录地址 -
执行同步块 A 00(轻量锁)线程 1 锁记录地址 -
访问同步块 B,把 Mark 复制到 线程 1 的锁记录 00(轻量锁)线程 1 锁记录地址 -
CAS 修改 Mark 为线程 1 锁记录 地址 00(轻量锁)线程 1 锁记录地址 -
失败(发现是自己的锁) 00(轻量锁)线程 1 锁记录地址 -
锁重入 00(轻量锁)线程 1 锁记录地址 -
执行同步块 B 00(轻量锁)线程 1 锁记录地址 -
同步块 B 执行完毕 00(轻量锁)线程 1 锁记录地址 -
同步块 A 执行完毕 00(轻量锁)线程 1 锁记录地址 -
成功(解锁) 01(无锁) -
- 01(无锁) 访问同步块 A,把 Mark 复制到 线程 2 的锁记录
- 01(无锁) CAS 修改 Mark 为线程 2 锁记录 地址
- 01(无锁) 成功(加锁)
- ... ...

锁膨胀

如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

static Object obj = new Object();
public static void method1(){
    synchronized( obj ){
        // 同步块
    }
}
线程1 对象Mark Word 线程2
访问同步块,把 Mark 复制到线程 1 的锁记录 01(无锁) -
CAS 修改 Mark 为线程 1 锁记录地址 01(无锁) -
成功(加锁) 00(轻量锁)线程 1 锁记录地址 -
执行同步块 00(轻量锁)线程 1 锁记录地址 -
执行同步块 00(轻量锁)线程 1 锁记录地址 访问同步块,把 Mark 复制 到线程 2
执行同步块 00(轻量锁)线程 1 锁记录地址 CAS 修改 Mark 为线程 2 锁 记录地址
执行同步块 00(轻量锁)线程 1 锁记录地址 失败(发现别人已经占了 锁)
执行同步块 00(轻量锁)线程 1 锁记录地址 CAS 修改 Mark 为重量锁
执行同步块 10(重量锁)重量锁指针 阻塞中
执行完毕 10(重量锁)重量锁指针 阻塞中
失败(解锁) 10(重量锁)重量锁指针 阻塞中
释放重量锁,唤起阻塞线程竞争 10(重量锁)重量锁指针 阻塞中
- 01(无锁) 竞争重量锁
- 10(重量锁) 成功(加锁)
- ... ...

重量锁

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退 出了同步块,释放了锁),这时当前线程就可以避免阻塞。 在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能 性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。

  • 自旋占用CPU时间,单核CPU自旋就是浪费,多核CPU自旋才能发挥优势。
  • java 7之后不能控制是否开启自旋功能

自旋重试成功的情况

线程1 (CPU1上) 对象 Mark 线程2 (CPU2上)
- 10(重量锁) -
访问同步块,获取 monitor 10(重量锁)重量锁指针 -
成功(加锁) 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 访问同步块,获取 monitor
执行同步块 10(重量锁)重量锁指针 自旋重试
执行完毕 10(重量锁)重量锁指针 自旋重试
成功(解锁) 01(无锁) 自旋重试
- 10(重量锁)重量锁指针 成功(加锁)
- 10(重量锁)重量锁指针 执行同步块
- ... ...

自旋重试失败的情况

线程1 (CPU1上) 对象 Mark 线程2 (CPU2上)
- 10(重量锁) -
访问同步块,获取 monitor 10(重量锁)重量锁指针 -
成功(加锁) 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 -
执行同步块 10(重量锁)重量锁指针 访问同步块,获取 monitor
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 自旋重试
执行同步块 10(重量锁)重量锁指针 阻塞
- ... ...

偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入 仍然需要执行CAS操作。Java 6中引入了偏向锁来做进一步优化:只有第一次使用 CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS.

  • 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW)

  • 访问对象的 hashCode 也会撤销偏向锁

  • 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2, 重偏向会重置对象的 Thread ID

  • 撤销偏向和重偏向都是批量进行的,以类为单位

  • 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的

  • 可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁

其他优化

减少上锁时间

同步代码块中尽量短

减少锁的粒度
  • ConcurrentHashMap
  • LongAdder分为base 和cells两部分。没有并发争用的时候或者是cells 数组正在初始化的时候,会使用CAS来累加值到base,有并发争用,会初始化cells数组,数组有多少个cell,就允许有多少线程并行修改,最后将数组中每个cell累加,再加上base就是最终的值
  • LinkedBlockingQueue 入队和出队使用不同的锁,相对于LinkedBlockingArray只有一 个 锁效率要高
锁粗化

多次循环进入同步块不如同步块内多次循环 另外 JVM 可能会做如下优化,把多次 append 的加锁操作 粗化为一次(因为都是对同一个对象加锁,没必要重入多次)

锁消除

JVM 会进行代码的逃逸分析,例如某个加锁对象是方法内局部变量,不会被其它线程所访问到,这时候 就会被即时编译器忽略掉所有同步操作。

读写分离
  • CopyOnWriteArrayList

  • ConyOnWriteSet

posted @ 2021-03-08 09:34  nuoxin  阅读(79)  评论(0)    收藏  举报