admin管理员组

文章数量:1599275

本身整合了如下视频的笔记,并进行了整理:尚硅谷周阳、张龙、黑马程序员

  • 黑马ppt非常好:https://download.csdn/download/hancoder/12834607
  • 本文及JVM系列笔记地址:https://blog.csdn/hancoder/category_10345348.html
  • 可以结合思维导图观看:https://www.processon/view/link/614c8ccaf346fb125807a4e2
  • 多线程并发、JMM等笔记:https://blog.csdn/hancoder/article/details/105740321

内容算是笔记充分了,张龙的代码附在文尾,文字部分整合到了正文中

1_介绍

1.1_什么是JVM

定义:java virtual meachine -java运行时环境(java二进制字节码的运行环境)。JVM是运行在操作系统之上的,它与硬件没有直接的交互。Java 虚拟机(JVM)是运行 Java 字节码的虚拟机。JVM 有针对不同系统的特定实现(Windows,Linux,macOS),目的是使用相同的字节码,它们都会给出相同的结果。
好处:

  1. 一次编写到处运行
  2. 自动内存管理,垃圾回收
  3. 数组下标越界检查
  4. 多态

我们需要格外注意的是 .class->机器码 这一步。在这一步 JVM 类加载器首先加载字节码文件,然后通过解释器逐行解释执行,这种方式的执行速度会相对比较慢。而且,有些方法和代码块是经常需要被调用的(也就是所谓的热点代码),所以后面引进了 JIT 编译器,而 JIT 属于运行时编译。当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用。而我们知道,机器码的运行效率肯定是高于 Java 解释器的。这也解释了我们为什么经常会说 Java 是编译与解释共存的语言。

  • JDK 是 Java Development Kit,它是功能齐全的 Java SDK。它拥有 JRE 所拥有的一切,还有编译器(javac)和工具(如 javadoc 和 jdb)。它能够创建和编译程序。
  • JRE 是 Java 运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,包括 Java 虚拟机(JVM),Java 类库,java 命令和其他的一些基础构件。但是,它不能用于创建新程序。

如果你只是为了运行一下 Java 程序的话,那么你只需要安装 JRE 就可以了。如果你需要进行一些 Java 编程方面的工作,那么你就需要安装 JDK 了。但是,这不是绝对的。有时,即使您不打算在计算机上进行任何 Java 开发,仍然需要安装 JDK。例如,如果要使用 JSP 部署 Web 应用程序,那么从技术上讲,您只是在应用程序服务器中运行 Java 程序。那你为什么需要 JDK 呢?因为应用程序服务器会将 JSP 转换为 Java servlet,并且需要使用 JDK 来编译 servlet。

1.3_常见的JVM

Oracle JDK 和 OpenJDK 的对比:

对于 Java 7,没什么关键的地方。OpenJDK 项目主要基于 Sun 捐赠的 HotSpot 源代码。此外,OpenJDK 被选为 Java 7 的参考实现,由 Oracle 工程师维护。关于 JVM,JDK,JRE 和 OpenJDK 之间的区别,Oracle 博客帖子在 2012 年有一个更详细的答案:

问:OpenJDK 存储库中的源代码与用于构建 Oracle JDK 的代码之间有什么区别?

答:非常接近 - 我们的 Oracle JDK 版本构建过程基于 OpenJDK 7 构建,只添加了几个部分,例如部署代码,其中包括 Oracle 的 Java 插件和 Java WebStart 的实现,以及一些封闭的源代码派对组件,如图形光栅化器,一些开源的第三方组件,如 Rhino,以及一些零碎的东西,如附加文档或第三方字体。展望未来,我们的目的是开源 Oracle JDK 的所有部分,除了我们考虑商业功能的部分。

总结:

  1. Oracle JDK 大概每 6 个月发一次主要版本,而 OpenJDK 版本大概每三个月发布一次,详情参见:https://blogs.oracle/java-platform-group/update-and-faq-on-the-java-se-release-cadence 。
  2. OpenJDK 是一个参考模型并且是完全开源的,而 Oracle JDK 是 OpenJDK 的一个实现,并不是完全开源的;
  3. Oracle JDK 比 OpenJDK 更稳定。OpenJDK 和 Oracle JDK 的代码几乎相同,但 Oracle JDK 有更多的类和一些错误修复。因此,如果您想开发企业/商业软件,我建议您选择 Oracle JDK,因为它经过了彻底的测试和稳定。某些情况下,有些人提到在使用 OpenJDK 可能会遇到了许多应用程序崩溃的问题,但是,只需切换到 Oracle JDK 就可以解决问题;
  4. 在响应性和 JVM 性能方面,Oracle JDK 与 OpenJDK 相比提供了更好的性能;
  5. Oracle JDK 不会为即将发布的版本提供长期支持,用户每次都必须通过更新到最新版本获得支持来获取最新版本;
  6. Oracle JDK 根据二进制代码许可协议获得许可,而 OpenJDK 根据 GPL v2 许可获得许可。

字节码文件class以CA FE BACE开头

查看二进制码的软件是Binary Viewer

3_内存结构

各类溢出:

  • 方法区溢出:不断创建代理类 或 不断调用 字符串.intern
  • 栈溢出:递归
  • 堆溢出:list不断新增

不会出现溢出:

  • 程序计数器

3.0 预备知识:javap工具与JVM参数设置

反编译工作javap:解析class文件

助记符

https://blog.csdn/shi1122/article/details/8053605

// JVM虚拟机栈里有:操作数栈、局部变量表、方法返回地址、动态链接
// 下面的栈顶指的是操作数栈顶
助记符 ldc:表示将intfloat或者String类型的常量值从常量池中推送至操作数栈顶
助记符 bipush:表示将单字节(-128-127)的常量值推送到栈顶
助记符 sipush:表示将一个短整型值(-32768-32369)推送至栈顶
助记符 iconst_1:表示将int型的1推送至栈顶(iconst_m1到iconst_5)
当int取值-1~5采用iconst指令,
取值-128~127采用bipush指令,
取值-32768~32767采用sipush指令,
取值-2147483648~2147483647采用 ldc 指令。

将一个局部变量加载到操纵栈的指令包括:iload、iload_、lload…
将一个数值从操作数栈存储到局部变量表的指令包括:istore、istore_、lstore…

偏移地址   操作指令  java源代码
JVM参数设置

JVM调优主要就是通过定制JVM运行参数来提高JAVA应用程度的运行数据
JVM参数大致可以分为三类:

  • 标准指令:-开头,这些是所有的HotSpot都支持的参数。可以用java -help打印出来。
  • 非标准指令:-X开头,这些指令通常是跟特定的HotSpot版本对应的。可以用java -X以打印出来。
  • 不稳定参数:-XX开头,这一类参数是跟特定HotSpot版本对应的,并且变化非常大,详细的文档资料非常少。

java -XX:+PrintCommandLineFlags 查看当前命令的不稳定命令

java -XX:+PrntFlagsInitial 查看所有不稳定指令的默认值

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

https://docs.oracle/javase/8/docs/technotes/tools/windows/index.html

虚拟机设置官方文档:https://docs.oracle/javase/8/docs/technotes/tools/unix/java.html

https://www.oracle/technetwork/java/javase/tech/vmoptions-jsp-140102.html

https://docs.oracle/javase/specs/jvms/se8/html/index.html

https://docs.oracle/javase/specs/index.html

https://docs.oracle/javase/8/

3.1、程序计数器

  • 定义: Program Counter Register程序计数器(PC寄存器),就是一个指针,指向方法区中的方法字节码
  • 作用: 记住下一条jvm指令的执行地址,用以完成分支、循环、跳转、异常处理、线程恢复等基础功能。。
  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  • 线程私有,这样线程切换后cpu知道从哪执行。是一个非常小的内存空间
  • 程序计数器是内存中唯一一个不会出现内存溢出(OutOfMemory=OOM)的区域
  • 需要注意的是,如果执行的是 native 方法,那么程序计数器记录的是 undefined 地址,只有执行的是 Java 代码时程序计数器记录的才是下一条指令的地址。
  • 场景:多线程时。JVM的多线程是通过CPU时间片轮转来实现的,某个线程在执行的过程中可能会因为时间片耗尽而挂起。当它再次获取时间片时,需要从挂起的地方继续执行。

3.2、JVM

定义: 栈也叫栈内存,主管Java程序的运行。

栈帧:栈中的数据都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法(Method)和运行期数据的数据集。每个方法执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每一个方法从调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。每个线程都只有一个活动栈帧,对应着线程当前执行的方法。活动栈帧(当前栈帧)即栈顶的帧。(在IDEA的Frames窗口中可以查看调用的帧)

1.每个线程都有自己的栈

2.JVM栈是由栈帧组成的:在这个线程上正在执行的每个方法都对应各自的一个栈帧

3.栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息

4.JVM直接对java栈的操作只有两个,就是对栈帧的压栈和出栈,遵循先进后出/后进先出的和原则。

6.执行引擎运行的所有字节码指令只针对当前栈帧进行操作

7.如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前栈帧。

8.不同线程中所包含的栈帧是不允许相互引用的,即不可能在另一个栈帧中引用另外一个线程的栈帧

9.如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧

10.Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。

  • 线程私有:在线程创建时创建,它的生命期是跟随线程的生命期,线程结束栈内存也就释放,
  • 对于栈来说不存在垃圾回收问题,只要线程一结束该栈就Over。
  • -Xss设置栈内存大小,一般-Xss=1M
    • 1 局部变量(Local Variables):输入参数和输出参数以及方法内的变量;8种基本类型的变量+对象的引用变量。栈里存放的是对象的引用ref,而对象实际上是存在堆里面的,对象引用ref指向堆里对象。实际上引用还指向了class文件,所以ref有两种指向情况:
      • ①ref先指向了两个指针,两个指针又分别指向对象数据+对象所属的类型Klass(元数据class信息,元数据一个类只有一份,在方法区中,这不就是synchronized里说的对象头)。
    • 2 操作数栈Operand Stack:记录出栈、入栈的操作;(记录和JVM栈区分)
  • 栈内存溢出OOM:栈帧过多,栈被撑破了(递归)、栈帧过大(交叉引用)。栈有OOM但没有GC

栈运行原理:

当一个方法A被调用时就产生了一个栈帧 F1,并被压入到栈中,
A方法又调用了 B方法,于是产生栈帧 F2 也被压入栈,
B方法又调用了 C方法,于是产生栈帧 F3 也被压入栈,
……
执行完毕后,先弹出F3栈帧,再弹出F2栈帧,再弹出F1栈帧……

线程的局部变量是否线程安全?
答: 不一定。方法内的局部变量且没有逃离方法的作用访问时,是线程安全的。如果局部变量引用了对象,由于对象存在于堆中,一般其他线程可以访问修改,需要考虑线程安全。线程私有的,就不用考虑线程安全。是static的,就得考虑线程安全。

栈溢出

java虚拟机规范允许Java栈的大小是动态的或者是固定不变的

如果采用固定大小的Java虚拟机栈,那每一个线程的java虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过java虚拟机栈允许的最大容量,java虚拟机将会抛出一个 StackOverFlowError异常

public class StackErrorTest {
    public static void main(String[] args) {
        main(args);
    }
}

如果java虚拟机栈可以动态拓展,并且在尝试拓展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那java虚拟机将会抛出一个 OutOfMemoryError异常

关于Error我们再多说一点,上面的讨论不涉及Exception
首先Exception和Error都是继承于Throwable 类,在 Java 中只有 Throwable 类型的实例才可以被抛出(throw)或者捕获(catch),它是异常处理机制的基本组成类型。

Exception和Error体现了JAVA这门语言对于异常处理的两种方式。
Exception是java程序运行中可预料的异常情况,咱们可以获取到这种异常,并且对这种异常进行业务外的处理。

Error是java程序运行中不可预料的异常情况,这种异常发生以后,会直接导致JVM不可处理或者不可恢复的情况。所以这种异常不可能抓取到,比如OutOfMemoryError、NoClassDefFoundError等。

其中的Exception又分为检查性异常和非检查性异常。两个根本的区别在于,检查性异常 必须在编写代码时,使用try catch捕获(比如:IOException异常)。非检查性异常 在代码编写使,可以忽略捕获操作(比如:ArrayIndexOutOfBoundsException),这种异常是在代码编写或者使用过程中通过规范可以避免发生的。

每个栈帧中存储着

1.局部变量表(Local Variables)

2.操作数栈(Operand Stack)(或表达式栈)

3.动态链接(Dynamic Linking)(或指向"运行时常量池"的方法引用)

4.方法返回地址(Return Adress)(或方法正常退出或者异常退出的定义)

5.一些附加信息

public class Main11 {
    int a = 0;

    public Main11() {
    }

    public int add(int a) {
        System.out.println(111);
        return 1;
    }
}
G:\test\target\classes>javap -v Main11.class
Classfile /G:/test/target/classes/Main11.class
  Last modified 2020-8-27; size 485 bytes
  MD5 checksum 1a7c4c8ffff290383477e59505da70e8
  Compiled from "Main11.java"
public class Main11
  minor version: 0
  major version: 49
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #5.#21         // Main11.a:I
   #3 = Fieldref           #22.#23        // java/lang/System.out:Ljava/io/PrintStream;
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(I)V
   #5 = Class              #26            // Main11
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               a
   #8 = Utf8               I
   #9 = Utf8               <init>
  #10 = Utf8               ()V
  #11 = Utf8               Code
  #12 = Utf8               LineNumberTable
  #13 = Utf8               LocalVariableTable
  #14 = Utf8               this
  #15 = Utf8               LMain11;
  #16 = Utf8               add
  #17 = Utf8               (I)I
  #18 = Utf8               SourceFile
  #19 = Utf8               Main11.java
  #20 = NameAndType        #9:#10         // "<init>":()V
  #21 = NameAndType        #7:#8          // a:I
  #22 = Class              #28            // java/lang/System
  #23 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(I)V
  #26 = Utf8               Main11
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (I)V
{
  int a;
    descriptor: I
    flags:

  public Main11();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_0
         6: putfield      #2                  // Field a:I
         9: return
      LineNumberTable:
        line 1: 0
        line 4: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  this   LMain11;

  public int add(int);
    descriptor: (I)I
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: bipush        111
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V
         8: iconst_1
         9: ireturn
      LineNumberTable:
        line 7: 0
        line 8: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  this   LMain11;
            0      10     1     a   I
}
SourceFile: "Main11.java"
3.2.1 局部变量表

1.局部变量表(局部变量数组、本地变量表)

2.主要用于存储方法参数和定义在方法体内的局部变量这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddresslndexing

3.由于局部变量表是建立在线程的栈上,是线程私有的数据(更是栈帧私有的数据),因此不存在数据安全问题

4.局部变量表所需的容量大小是在编译期根据方法确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的

**5.方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。**对一个函数而言,他的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间。

**6.局部变量表中的变量只在当前方法调用中有效。**在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。

利用javap命令对字节码文件进行解析查看main()方法对应栈帧的【局部变量表】,如图:

也可以在IDEA 上安装jclasslib byte viewcoder插件查看方法内部字节码信息剖析,以main()方法为例

变量槽slot

1.参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束

2.局部变量表,最基本的存储单元是Slot(变量槽)

3.局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。

4.在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。

byte、short、char、float在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true;

long和double则占据两个slot。

5.JVM会为局部变量表中的每一个slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值

6.当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照声明顺序被复制到局部变量表中的每一个slot上

7.如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:访问long或者double类型变量)

8.如果当前帧是构造方法或非静态方法,那么==该对象引用this将会存放在index为0的slot处==,其余的参数按照参数表顺序排列。

9.静态方法中不能引用this,是因为静态方法所对应的栈帧当中的局部变量表中不存在this

示例代码:

public class LocalVariablesTest {

    private int count = 1;
    //静态方法不能使用this
    public static void testStatic(){
        //编译错误,因为this变量不存在与当前方法的局部变量表中!!!
        System.out.println(this.count);
    }
}
slot的重复利用

栈帧中的局部变量表中的槽位是可以重复利用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

private void test2() {// slot的重复利用
    int a = 0;
    {
        int b = 0;
        b = a+1;
    }
    //变量c使用之前以及经销毁的变量b占据的slot位置
    int c = a+1;
}

上述代码对应的栈帧中局部变量表中一共有多少个slot,或者说局部变量表的长度是几?

答案是3:

变量b的作用域是

{
    int b = 0;
    b = a+1;
}

this占0号、a单独占1个槽号、c重复使用了b的槽号

静态变量vs局部变量

变量的分类:

  • 按照数据类型分:
    • ①基本数据类型;
    • ②引用数据类型;
  • 按照在类中声明的位置分:
    • ①成员变量:在使用前,都经历过默认初始化赋值
      • static修饰:类变量:类加载链接的准备preparation阶段给类变量默认赋0值——>初始化阶段initialization给类变量显式赋值即静态代码块赋值;
      • 不被static修饰:实例变量:随着对象的创建,会在堆空间分配实例变量空间,并进行默认赋值
    • ②局部变量:在使用前,必须要进行显式赋值的!否则,编译不通过

补充说明

  • 在栈帧中,与性能调优关系最为密切的部分就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递
  • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
3.2.2 操作数栈(Operand Stack)

1.栈 :可以使用数组或者链表来实现

2.每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出的操作数栈,也可以成为表达式栈

3.操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)或出栈(pop)

某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用他们后再把结果压入栈。(如字节码指令bipush操作)

比如下面的代码的意思:https://blog.csdn/shi1122/article/details/8053605

  • bipush 15:将单字节的常量值(-128~127)即15推送到栈顶
  • isstore_1:将栈顶int型数值存入第二个本地变量i
  • bipush 8:将单字节的常量值(-128~127)即8推送到栈顶
  • isstore_2:将栈顶int型数值存入第三个本地变量j
  • iload_1:将第二个int型本地变量推送至栈顶,即15
  • iload_2:将第三个int型本地变量推送至栈顶,即8
  • iadd:将栈顶两int型数值相加并将结果压入栈顶 ,即15+8=23
  • istore_3:将栈顶int型数值(23)存入第四个本地变量k
  • return: 返回
操作数栈特点
  • 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
  • 操作数栈就是jvm执行引擎的一个工作区,当一个方法开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
  • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译器就定义好了,保存在方法的code属性中,为max_stack的值。
  • 栈中的任何一个元素都是可以任意的java数据类型
    • 32bit的类型占用一个栈单位深度
    • 64bit的类型占用两个栈深度单位
  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈push和出栈pop操作来完成一次数据访问
  • **如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,**并更新PC寄存器中下一条需要执行的字节码指令。
  • 操作数栈中的元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类验证阶段的数据流分析阶段要再次验证。
  • 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
操作数栈代码追踪
// JVM虚拟机栈里有:操作数栈、局部变量表、方法返回地址、动态链接
// 下面的栈顶指的是操作数栈顶
// push是放到操作数栈的栈顶   load是从局部变量表加载到操作数栈  store是从操作数栈放到局部变量表
助记符 ldc:表示将intfloat或者String类型的常量值从常量池中推送至操作数栈顶
助记符 bipush:表示将单字节(-128-127)的常量值推送到栈顶
助记符 sipush:表示将一个短整型值(-32768-32369)推送至栈顶
助记符 iconst_1:表示将int型的1推送至栈顶(iconst_m1到iconst_5)
当int取值-1~5采用iconst指令,
取值-128~127采用bipush指令,
取值-32768~32767采用sipush指令,
取值-2147483648~2147483647采用 ldc 指令。

将一个局部变量加载到操纵栈的指令包括:iload、iload_、lload…
将一个数值从操作数栈存储到局部变量表的指令包括:istore、istore_、lstore…

偏移地址   操作指令  java源代码

结合上图结合下面的图来看一下一个方法(栈帧)的执行过程

注意局部变量表的数量没有变化

push是压入栈,store是从栈中pop然后赋值给局部变量

数字代表第几个变量

①15入栈;②存储15,15进入局部变量表

注意:局部变量表的0号位被构造器占用,这里的15从局部变量表1号开始

③压入8;④8出栈,存储8进入局部变量表;

⑤从局部变量表中把索引为1和2的是数据取出来,放到操作数栈;⑥iadd相加操作

⑦iadd操作结果23出栈⑧将23存储在局部变量表索引为3的位置上istore_3

bipush sipush

如果有返回值是什么样的?

  • 返回的那个变量把值压入栈然后当前栈帧结束
  • 下一个栈帧一上来就把就把值压入当前栈

i++和++i有什么区别:++i是先在局部变量表中自增后(这么理解即可),然后把重新取到栈中。

i++是反过来的

//i++方式
int i1 = 100;
System.out.println(i1++);
//++i方式
int i2 = 100;
System.out.println(++i2);

//i++方式字节码指令
 0 bipush 100 //JVM采用bipush指令将常量压入栈中【操作数栈】
 2 istore_1 //将一个数值从操作数栈存储到局部变量表i1
 3 getstatic #2 <java/lang/System.out>
 6 iload_1    //从局部表中的第一个变量的值100出栈到操作数栈中
 7 iinc 1 by 1 //局部表中的第一个位置的变量按常量1增加 第一个位置1代表局部变量索引,第二个1表示步长
10 invokevirtual #3 <java/io/PrintStream.println>
 结果为: 100 
 
 //++i方式字节码指令
13 bipush 100
15 istore_2
16 getstatic #2 <java/lang/System.out>
19 iinc 2 by 1 //局部表中的第二个位置的变量按常量1增加 
22 iload_2 //从局部表中的第一个变量的值出栈到操作数栈中
23 invokevirtual #3 <java/io/PrintStream.println>
结果为: 101
  • //
    int i1=10;
    i1++;
    int i2=10;
    ++i2;
    
    //
    int i3=10;
    int i4=i3++;
    int i5=10;
    int i6=++i5;
    
    //
    int i7=10;
    i7=i7++;
    int i8=10;
    i8=++i8;
    
    //
    int i9=10;
    i10=i9++ + ++i9;
    
栈顶缓存技术ToS(Top-of-Stack Cashing)
  • 基于栈式架构的虚拟机所使用的零地址指令(即不考虑地址,单纯入栈出栈)更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数
  • 由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率
3.2.3 动态链接(Dynamic Linking)
  • 每一个栈帧内部都包含一个指向运行时常量池Constant pool或该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。比如invoke-dynamic指令
  • Java源文件被编译成字节码文件中时,所有的变量和方法引用都作为符号引用(symbolic Refenrence)保存在class字节码文件(javap反编译查看)的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用(#)最终转换为调用方法的直接引用。

这部分可以结合类加载链接阶段的解析阶段看

1.运行时常量池位于方法区(后面变到堆里了)

注意: JDK1.7 及之后版本的 JVM 已经将运行时常量池 从方法区中移了出来,在 Java 中开辟了一块区域存放运行时常量池。

字节码中的常量池结构如下:

这个动态链接引用指向的是当前类的运行时常量池

为什么需要常量池呢?
常量池的作用,就是为了提供一些符号和常量,便于指令的识别。下面提供一张测试类的运行时字节码文件格式

通过javap -v Test.class反编译出来的内容如下

上面的内容也可以用IDEA的jclasslib插件查看。下面看方法中调用方法都是指向运行时常量池的引用

上图在jclasslib插件的code中可以查看,下图不是该类的代码,但可以通过他看看规则

invokeVirtual代表调用方法,是#5所代表的字母的方法

  • getstatic:获取静态变量System.out
  • invokevirtual调用方法
3.2.3.1 方法的调用

多态:在JVM中,将符号引用转换‘为调用方法的直接引用与方法的绑定机制相关

  • 静态链接
    当一个 字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
    • 绑定是一个字段、方法或者类将符号引用被替换为直接引用的过程,这仅仅发生一次。
    • 早期绑定
      早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
  • 动态链接
    如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。
    • 晚期绑定
      如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。

Java中任何一个普通的方法其实都具备虚函数的特征,它们相当于C++语言中的虚函数(C++中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法。

3.2.3.2 虚方法和非虚方法
子类对象的多态性使用前提:实际开发编写代码中用的接口,实际执行是导入的的三方jar包已经实现的功能
①类的继承关系(父类的声明)②方法的重写(子类的实现)

非虚方法

  • 如果方法在编译器就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法
  • 非虚方法:静态方法、私有方法、final方法、实例构造器(实例已经确定,this()表示本类的构造器)、父类方法(super调用)都是非虚方法

其他所有体现多态特性的方法称为虚方法

动态类型语言和静态类型语言。

动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。

说的再直白一点就是,静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。

Java语言中方法的调用:方法重写的本质

  • 1.找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C(对象头里有Klass)。
  • 2.如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,
    • 如果校验通过则返回这个方法的直接引用,查找过程结束;
    • 如果校验不通过,则返回java.lang.IllegalAccessError异常。
  • 3.否则(没找到),按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
  • 4.如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

IllegalAccessError介绍:程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。

虚方法表

方法的调用:虚方法表

  • 在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(virtual method table) (非虚方法不会出现在表中)来实现。使用索引表来代替查找。
    • virtual dispatch 机制会首先从 receiver(被调用方法的对象)的类的实现中查找对应的方法,如果没找到,则去父类查找,直到找到函数并实现调用,而不是依赖于引用的类型。
  • 每个类中都有一个虚方法表,表中存放着各个方法的实际入口。
  • 那么虚方法表什么时候被创建?
    虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。
  • 虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。
  • 为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。

https://blog.csdn/qq_29310729/article/details/106167943

针对方法调用动态分派的过程,虚拟机会在类的方法区建立一个虚拟方法表的数据结构(virtual method table,vtable),
针对于invokeinterface指令来说,虚拟机会建立一个叫做接口方法表的数据结构(interface method table,itable)

方法表会在类的连接阶段初始化,方法表存储的是该类方法入口的一个映射,比如父类的方法A的索引号是1,方法B的索引号是2。。。
如果子类继承了父类,但是某个父类的方法没有被子类重写,那么在子类的方法表里该方法指向的是父类的方法的入口,子类并不会重新生成一个方法,然后让方法表去指向这个生成的,这样做是没有意义的。

如果子类重写了父类的方法,那么子类这个被重写的方法的索引和父类的该方法的索引是一致。比如父类A的test方法被子类C重写了,那么子类C的test方法的索引和父类A的test方法的索引都是1(打个比方),这样做的目的是为了快速查找,比如说在子类里边找不到一个方法索引为1的方法,那么jvm会直接去父类查找方法索引为1的方法,不需要重新在父类里边遍历。

方法调用:虚方法表

3.2.3.3 虚拟机中方法调用指令

普通调用指令:

  • 1.invokestatic:调用静态方法,解析阶段确定唯一方法版本;(非虚方法)
  • 2.invokespecial:调用<init>方法、私有父类方法,解析阶段确定唯一方法版本;(非虚方法)
  • 3.invokevirtual:调用所有虚方法;(虚方法)
    • final修饰的除外,JVM会把final方法调用也归为invokevirtual指令,但要注意final方法调用不是虚方法
  • 4.invokeinterface:调用接口方法;(虚方法)
  • 动态调用指令(Java7新增):
  • 5.invokedynamic:动态解析出需要调用的方法,然后执行 .

前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。

  • JVM字节码指令集一直比较稳定,一直到Java7中才增加了一个invokedynamic指令,这是Java为了实现「动态类型语言」支持而做的一种改进。
  • 但是在Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在Java中才有 了直接的生成方式。
  • Java7中增加的动态语言类型支持的本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言的编译器。
interface MethodInterface{
    void methodA();
}

class Father{
    public Father(){
        System.out.println("father的构造器");
    }
    public static void showStatic(String str){
        System.out.println("father "+ str);
    }
    public final void showFinal(){
        System.out.println("father show final");
    }
    public void showCommon(){
        System.out.println("father 普通方法");
    }

}
public class Son extends Father{
    public Son(){
        //invokespecial
        super();
    }
    public Son(int age){
        //invokespecial
        this();
    }
    //不是重写的父类的静态方法,因为静态方法不能被重写
    public static void showStatic(String str){
        System.out.println("son "+ str);
    }
    public void showPrivate(String str){// private
        System.out.println("son private "+str);
    }
    public void show(){
        //invokestatic
        showStatic("p3wj.top");
        //invokestatic
        Father.showStatic("good!");
        //invokespecial
        showPrivate("hello!");
        //invokespecial
        super.showCommon();
        
        //虚方法:编译期间无法确定下来的
        //invokevirtual,虽然是这个但是被final修饰他不是一个虚方法
        showFinal();
        //invokespecial,加上super,显示地表示是一个父类地方法
        super.showFinal();
        
        //invokevirtural,因为有可能该子类会重写这方法,如果加上super就是invokespecial
        showCommon();
        info();

        MethodInterface in = null;
        //invokeinterface
        in.methodA();

    }
    public void info(){

    }
    public void display(Father f){
        f.showCommon();
    }

    public static void main(String[] args) {
        Son son = new Son();
        son.show();
    }
}
重载与重写

方法重载是静态的,是编译期行为;

方法重写是动态的,是运行期行为。

上面的代码Animal中有一个方法重载了两次,子类Dog将该重载都重写了

如果我们new Animal调用他的方法,因为方法确定,所以是静态分派。

而如果new Dog调用他的方法,那么指令将是invokeVirtual,意思是动态分派

针对于方法调用动态分派的过程,虚拟机会在类的方法区建立一个虚方法表的数据结构(virtual method table,也简称vtable)。

针对于invokeinterface指令来说,虚拟机会建议一个叫做接口方法的数据结构(interface method table,也简单itable),其查找机制基本类似,下面用一个示例图来对其进行理解:

其中虚方法表vtable中每一项都存放的是特定方法实际真正的入口调用地址,其中有一种情况,就是子类Dog只继承了Animal但没有重写过Animal父类的方法,如下:

如果Dog子类重写了父类的方法,那么当然方法就会存在于Dog的虚方法表中啦。所以说对于Object类来说里面定义了很多的方法,但是实际我们编写的类可能很多都没有重写它里面的方法,那么其虚方法表中都是存在Object当中而非拷贝一份到我们具体子类当中。

另外虚方法表vtable还有一点就是:只要是子类和父类的方法描述是一样的,那么它们在父类和子类的索引是一样的,这样当查找子类的方法时,由于索引跟父类是一模一样的,则直接拿着子类该方法的索引到父类的方法表中的对应的索引就直接可以定位到了,比较高效。一般虚方法表都是在类的连接阶段【类加载有加载、连接、初始化阶段】进行的初始化。

下面来看一下这个程序,比较容易犯错误,看一下伪代码:

如果说这样调用:

肯定是木有问题的,很显然就是Child的一个静态调用,那如果这样调用呢?

这个结果是编译都通不过,不信的话咱们以之前的例子稍加修改一下:

为啥呢?其实这个从字节码上就能够解释,对于这个程序:

其实会对应于字节码的invokevirtural指令,是静态行为,其参数为Parent.test3(),就类似于:

而Parent类很明显没有定义test3()这个方法嘛,当然就编译不过了。

3.2.4 方法返回地址(主要针对于正常退出的情况)
  • 存放调用该方法的pc寄存器的值
  • 一个方法的结束,有两种方式:
    • 正常执行完成
    • 出现未处理的异常,非正常退出
      • 在方法执行的过程中遇到了异常(Exception) ,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。简称异常完成出口。
  • 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置
    • 方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。
    • 而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
  • 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

交给执行引擎,去执行后续的操作

区别:

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

当一个方法开始执行后,只有两种方式可以退出这个方法:
1、执行引擎遇到任意-一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;

  • ➢一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。
  • ➢在字节码指令中,返回指令包含ireturn (当返回值是boolean、byte、char、short和int类型时使用)、lreturn、 freturn、 dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。

2、方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。

以上数字为字节码指令地址

如果在4-16行出现异常,则用19行处理,针对任何类型

ExceptionTable:
fromtotargettype
41619any
192119any

3.3_本地方法栈

简单地讲,一个Native Method就 是一个Java调用非Java代码的接口。 一个Native Method是这样 一个Java方法:该方法的实现由非Java语言实现,比如C。这个特征并非Java所特有,很多其它的编程语言都有这一机制,比如在C++中,你可以用extern "C"告 知C++编译器去调用-一个C的函数。

“A native method is a Java method whose implementation isprovided by non-java code.”

在定义一个native method时,并不提供实现体(有些像定义一个Javainterface),因为其实现体是由非java语言在外面实现的。

  • 虚拟机栈: 每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。
  • 本地方法栈: 和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序。

public class IHaveNatives {
    public native void Native1(int x);

    native static public long Native2();

    native synchronized private float Native3(Object o);

    native void Native4(int[] art) throws Exception;
}

本地方法栈
●Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。
●本地方法栈,也是线程私有的。
允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)

➢如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverflowError 异常。
➢如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个OutOfMemoryError 异常。

●本地方法是使用C语言实现的。
●它的具体做法是Native Method Stack中登记native方法,在Execution Engine 执行时加载本地方法库。

●当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟
机限制的世界。它和虚拟机拥有同样的权限。

➢本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。
➢它甚至可以直接使用本地处理器中的寄存器
➢直接从本地内存的堆中分配任意数量的内存。

●并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求
**本地方法栈的使用语言、具体实现方式、数据结构等。**如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。
●在Hotspot JVM中, 直接将本地方法栈和虚拟机栈合二为一。

本地方法栈:JAVA虚拟机调用本地方法时,给本地方法分配的内存空间。

本地方法native method:不是由java编写的代码,如C写的与操作系统底层打交道的方法。如Object类中的protected native Object clone();

本地方法栈类似于虚拟机栈,也是线程私有。

不同点:本地方法栈服务的对象是jvm运行的native方法,而虚拟机栈服务的是jvm执行的java方法。

本地接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++程序,Java 诞生的时候是 C/C++横行的时候,要想立足,必须有调用 C/C++程序,于是就在内存中专门开辟了一块区域处理标记为native的代码,它的具体做法是 Native Method Stack中登记 native方法,在Execution Engine 执行时加载native libraies。

目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间的通信很发达,比如可以使用 Socket通信,也可以使用Web Service等等,不多做介绍。

3.4_堆

定义: Heap,通过new关键字创建的对象,都存放在堆内存中。

特点

  • 线程共享,堆中的对象都存在线程安全的问题。
  • 垃圾回收,垃圾回收机制重点区域。

根据垃圾回收的划分,逻辑上将堆划分为:

  • 新生代Young Generation
    • Eden伊甸园
    • 幸存区Survivor From
    • 幸存区Survivor To
  • 老年代Tenure generation
  • JDK7之前为Permanent永久区,JDK8之后为元空间
//演示堆内存溢出  //-Xms  -Xmx
int i=0;

List<String> list=new ArrayList<>();
String a="hello";
while(true){
    list.add(a);//list对象始终被关联,无法被回收,死循环不断将list规模变大,最终大于堆内存大小,内存溢出。
    a=a+a;
    i++;
}
//对象什么时候释放:
public void method(){
    Object obj= new Object();
}
生成了2部分的内存区域:1:obj这个引用变量,因为是方法内的变量,放到JVM栈里面(引用占4个字节)。2:真正Objectclass的实例对象放到Heap里面。(空对象栈8个字节)
方法结束后,对应栈中的变量马上回收,但是对重的对象要等到GC来回收

一个进程对应一个JVM实例,对应一个runtime data area运行时数据区

●《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。(涉及到物理内存和虚拟内存)
●所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(ThreadLocal Allocation Buffer, TLAB)

堆空间大小的设置和查看:

  • Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过下面选项设置
    • "-Xms"用于表示堆区的起始内存,等价于-XX:InitialHeapSize
    • "-Xmx"则用于表示堆区的最大内存,等价于-XX:MaxHeapSize
    • 通常会将-Xms 和-Xmx两个参数配置相同的值,其目的是为了能够在java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
    • 默认情况下,初始内存大小:物理电脑内存大小/ 64
      最大内存大小:物理电脑内存大小/ 4
Runtime.getRuntime().totalMemory();
Runtime.getRuntime().maxMemory();
堆内存诊断
  • jps工具
    • 查看系统有哪些进程。jps
  • jmap工具
    • 查看堆内存使用情况 jmap -heap 【进程号】
  • jconsole工具
    • 图形界面,多功能检测工具,连续监测。
  • jvisualVM

一个简单的案例

  • 执行多次垃圾回收后,内存占用依然很高
    • 1.控制台输入jVitualVM,在左边选择对应进程,右面点“堆dump”。
    • 2.点击“查找”,点击第一条占用内存最大的记录。
    • 3.找到问题所在,list中有过多大对象student,无法被清除。
public class Demo2 {
    public static  void main(String[] args) throws InterruptedException {
        List<student> list = new ArrayList<>();
        for (int i = 0; i < 200;i++){
            list.add(new student());
        }
        Thread.sleep(10000000000L);
    }
}

class student{
    private byte[] big = new byte[1024 * 1024];
}

有下面程序:

public class Demo1 {
    public static  void main(String[] args) throws InterruptedException {
        System.out.println("1....");
        Thread.sleep(30000);
        byte[] array =  new byte[1024 * 1024 * 10];//10M
        System.out.println("2....");
        Thread.sleep(30000);
        array = null;//array没有引用,可以被回收了
        System.gc();
        System.out.println("3...");
        Thread.sleep(1000000L);
    }
}

运行上面代码,首先在终端输入jps得到Demo进程PID,根据PID再通过jmap -heap PID每次查看进程占用内存情况:

//--------------jps--------------------------
D:\openSourceProject\jvm1>jps
8916 Launcher
9876 RemoteMavenServer36
11656
13976 Demo1 //这个就是我们的进程号
13756 Jps
    
//-------------jmap---------------------------
//按进程号查看堆情况
D:\openSourceProject\jvm1>jmap -heap 13976
Attaching to process ID 13976, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.221-b11

using thread-local object allocation.
Parallel GC with 8 thread(s)

Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 4276092928 (4078.0MB)
   NewSize                  = 89128960 (85.0MB)
   MaxNewSize               = 1425014784 (1359.0MB)
   OldSize                  = 179306496 (171.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 67108864 (64.0MB)
   used     = 6711104 (6.40020751953125MB)//6M
   free     = 60397760 (57.59979248046875MB)
   10.000324249267578% used
From Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
To Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
PS Old Generation
   capacity = 179306496 (171.0MB)
   used     = 0 (0.0MB)
   free     = 179306496 (171.0MB)
   0.0% used
3175 interned Strings occupying 260400 bytes.
       
//----------------jmap------------------------
//再次输入
D:\openSourceProject\jvm1>jmap -heap 13976//每次都得重新输入
Attaching to process ID 13976, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.221-b11

using thread-local object allocation.
Parallel GC with 8 thread(s)

Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 4276092928 (4078.0MB)
   NewSize                  = 89128960 (85.0MB)
   MaxNewSize               = 1425014784 (1359.0MB)
   OldSize                  = 179306496 (171.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 67108864 (64.0MB)
   used     = 17196880 (16.400222778320312MB)//16M=6+10
   free     = 49911984 (47.59977722167969MB)
   25.62534809112549% used
From Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
To Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
PS Old Generation
   capacity = 179306496 (171.0MB)
   used     = 0 (0.0MB)
   free     = 179306496 (171.0MB)
   0.0% used

3176 interned Strings occupying 260456 bytes.
//-------------jmap---------------------------
D:\openSourceProject\jvm1>jmap -heap 13976
Attaching to process ID 13976, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.221-b11

using thread-local object allocation.
Parallel GC with 8 thread(s)

Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 4276092928 (4078.0MB)
   NewSize                  = 89128960 (85.0MB)
   MaxNewSize               = 1425014784 (1359.0MB)
   OldSize                  = 179306496 (171.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 67108864 (64.0MB)
   used     = 1342200 (1.2800216674804688MB)//1M
   free     = 65766664 (62.71997833251953MB)
   2.0000338554382324% used
From Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
To Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
PS Old Generation
   capacity = 179306496 (171.0MB)
   used     = 1106008 (1.0547714233398438MB)
   free     = 178200488 (169.94522857666016MB)
   0.6168253937659905% used

3162 interned Strings occupying 259464 bytes.

3.5_方法区(元空间)

方法区Method Area:主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。(类信息和运行时常量池)

所有线程共享的一块区域,存储了每个类class结构的信息,包括:

这里的运行时常量池即栈帧里动态链接里执行的地方

同时也是java对象头里Kclass指向的地方

方法区是GC的非主要工作区域,java虚拟机规范表示可以不要求虚拟机在这区实现GC,这区GC的性价比一般比较低;在堆中,尤其是新生代,常规应用进行一次GC一般可以回收70%~95%的空间,而方法区的GC效率远小于此。当前的商业JVM都有实现方法区的GC,主要回收两部分:废弃常量和无用类。

类回收需要满足如下3个条件:

  • 该类所有的实例都已经被GC,也就是JVM中不存在该Class的任何实例
  • 加载该类的ClassLoader已经被GC
  • 该类对应的java.lang.Class对象没有在任何地方被引用,如:不能再任何地方通过反射访问该类的方法

在大量使用反射、动态代理、CGLib等字节码框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要JVM具备类下载的支持以保证方法区不会溢出。

从JDK1.8开始就没有永久代了,变为了元空间

	对于HotSpot虚拟机,很多开发者习惯将方法区称之为“永久代(Parmanent Gen)” ,但严格本质上说两者不同,或者说使用永久代来实现方法区而已,永久代是方法区(相当于是一个接口interface)的一个实现,jdk1.7的版本中,已经将原本放在永久代的字符串常量池移走。
	
永久区(java7之前有)
	永久存储区是一个常驻内存区域,用于存放JDK自身所携带的 Class,Interface 的元数据,也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭 JVM 才会释放此区域所占用的内存。
	永久代 Permanent Generation,从JDK1.8彻底废弃,使用元空间 meta space

下图演示了栈帧里的局部变量指向了堆与方法区

方法区溢出

HotSpot jdk1.7之前字符串常量池是方法区的一部分,方法区叫做“永久代”,在1.7之前无限的创建对象就会造成内存溢出,提示信息:PermGen space
而是用jdk1.7之后,开始逐步去永久代,就不会产生内存溢出。

方法区用于存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等,如果动态生成大量的Class文件,也会产生内存溢出。常见的场景还有:大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为java类)、基于OSGi的应用(即使是同一个类文件,被不同的类加载器加载也会视为不同的类)。

方法区用于存放Class的相关信息,如:类名,访问修饰符,常量池,字符描述,方法描述等。对于这个区域的测试,基本思路是运行时产生大量的类去填满方法区,直到溢出。虽然直接使用Java SE API也可以动态产生类(如反射时的GeneratedConstructorAccessor和动态代理等),但在本次试验使用CGLIB直接操作字节码运行时,生成大量的动态类。

值得注意的是,当前主流的很多框架 如:Spring,Hibernate对类进行增强时,都会使用到类似CGLIB这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的Class可以加载入内存。例如:

运行时常量池溢出

Java 永久代是非堆内存的组成部分,用来存放类名、访问修饰符、常量池、字段描述、方法描述等,因运行时常量池是方法区的一部分,所以这里也包含运行时常量池。我们可以通过 jvm 参数

 -XX:PermSize=10M 
 -XX:MaxPermSize=10M 来指定该区域的内存大小,
 -XX:PermSize 默认为物理内存的 1/64 ,
 -XX:MaxPermSize 默认为物理内存的 1/4 。

String.intern() 方法是一个 Native 方法,它的作用是:如果字符串常量池中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象;否则,将此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用。

在 JDK 1.6 及之前的版本中,由于常量池分配在永久代内,我们可以通过

-XX:PermSize 和 
-XX:MaxPermSize 限制方法区大小,从而间接限制其中常量池的容量,通过运行 java 
-XX:PermSize=8M 
-XX:MaxPermSize=8M 
RuntimeConstantPoolOom

下面的代码我们可以模仿一个运行时常量池内存溢出的情况:

List<String> list = new ArrayList<String>();
int i = 0;
while (true) {
    // intern
    list.add(String.valueOf(i++).intern());
}

运行结果如下

# ../jdk1.6.0_45/bin/java -XX:PermSize=8m -XX:MaxPermSize=8m RuntimeConstantPoolOom
Exception in thread "main" java.lang.OutOfMemoryError: PermGen space
    at java.lang.String.intern(Native Method)
    at RuntimeConstantPoolOom.main(RuntimeConstantPoolOom.java:9)
类溢出

还有一种情况就是我们可以通过不停的加载class来模拟方法区内存溢出,《深入理解java虚拟机》中借助 CGLIB 这类字节码技术模拟了这个异常,我们这里使用不同的 classloader 来实现(同一个类在不同的 classloader 中是不同的),代码如下

Set<Class<?>> classes = new HashSet<Class<?>>();
URL url = new File("").toURI().toURL();
URL[] urls = new URL[]{url};
while (true) {
    // 类加载器
    ClassLoader loader = new URLClassLoader(urls);
    // 加载类
    Class<?> loadClass = loader.loadClass(Object.class.getName());
    classes.add(loadClass);
}

运行结果如下:

[root@9683817ada51 oom]# ../jdk1.6.0_45/bin/java -XX:PermSize=2m -XX:MaxPermSize=2m MethodAreaOom
Error occurred during initialization of VM
java.lang.OutOfMemoryError: PermGen space
    at sun.net.www.ParseUtil.<clinit>(ParseUtil.java:31)
    at sun.misc.Launcher.getFileURL(Launcher.java:476)
    at sun.misc.Launcher$ExtClassLoader.getExtURLs(Launcher.java:187)
    at sun.misc.Launcher$ExtClassLoader.<init>(Launcher.java:158)
    at sun.misc.Launcher$ExtClassLoader$1.run(Launcher.java:142)
    at java.security.AccessController.doPrivileged(Native Method)
    at sun.misc.Launcher$ExtClassLoader.getExtClassLoader(Launcher.java:135)
    at sun.misc.Launcher.<init>(Launcher.java:55)
    at sun.misc.Launcher.<clinit>(Launcher.java:43)
    at java.lang.ClassLoader.initSystemClassLoader(ClassLoader.java:1337)
    at java.lang.ClassLoader.getSystemClassLoader(ClassLoader.java:1319)
JDK8

在 jdk1.8 上运行上面的代码将不会出现异常,因为 jdk1.8 已结去掉了永久代,当然 -XX:PermSize=2m -XX:MaxPermSize=2m 也将被忽略,如下

[root@9683817ada51 oom]# java -XX:PermSize=2m -XX:MaxPermSize=2m MethodAreaOom
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option PermSize=2m; support was removed in 8.0
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize=2m; support was removed in 8.0

jdk1.8 使用元空间( Metaspace )替代了永久代( PermSize ),因此我们可以在 1.8 中指定 Metaspace 的大小模拟上述两种情况

[root@9683817ada51 oom]# java -XX:MetaspaceSize=2m -XX:MaxMetaspaceSize=2m RuntimeConstantPoolOom
Error occurred during initialization of VM
java.lang.OutOfMemoryError: Metaspace
    <<no stack trace available>>
[root@9683817ada51 oom]# java -XX:MetaspaceSize=2m -XX:MaxMetaspaceSize=2m MethodAreaOom
Error occurred during initialization of VM
java.lang.OutOfMemoryError: Metaspace
    <<no stack trace available>>
CGLIB
import java.lang.reflect.Method;  
  
import net.sf.cglib.proxy.Enhancer;  
import net.sf.cglib.proxy.MethodInterceptor;  
import net.sf.cglib.proxy.MethodProxy;  
/** 
 * VM args -XX:PermSize=10M -XX:MaxPermSize=10M 
 * 
 */  
public class JavaMethodAreaOOM {  
    public static void main(String[] args) {  
        while (true) {  
            Enhancer enhancer = new Enhancer();  
            enhancer.setSuperclass(OOM.class);  
            enhancer.setUseCache(false);  
            enhancer.setCallback(new MethodInterceptor() {  
  
                @Override  
                public Object intercept(Object obj, Method arg1, Object[] args, MethodProxy proxy) throws Throwable {  
                    return proxy.invokeSuper(obj, args);  
                }  
            });  
            OOM oom = (OOM) enhancer.create();  
            oom.sayHello("Kevin LUAN");  
        }  
    }  
  
    static class OOM {  
        public String sayHello(String str) {  
            return "HI " + str;  
        }  
    }  
}  

Caused by: java.lang.OutOfMemoryError:PermGen space
JDK 1.7 64位运行结果如下:
java version "1.7.0_45"
Java(TM) SE Runtime Environment (build 1.7.0_45-b18)
Java HotSpot(TM) 64-Bit Server VM (build 24.45-b08, mixed mode)
Exception in thread "main" 
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"

方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾回收器回收掉,判定条件非常苛刻,在经常动态生成大量Class的应用中,需要特别注意类的回收状况。这类场景除了上面提到的程序使用GCLIB字节码增强外,常见的还用JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为JAVA类),基于OSGI的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。

#增加JVM 参数来快速定位下 Class load ,来定位下。

-XX:+TraceClassLoading -XX:+TraceClassUnloading

输出格式:[Loaded sun.rmi.server.LoaderHandler from /usr/local/java/jdk1.7.0/jre/lib/rt.jar]可以方便定位出增加的CLASS文件来源

运行时常量池
  • Class文件的常量池与方法区的运行时常量池:我们写的每一个Java类被编译后,就会形成一份class文件;每一个Class文件中,都维护着一个常量池(这个保存在类文件里面,不要与方法区的运行时常量池搞混)。
    • 这个常量池的内容,在类加载的时候,被复制到方法区的运行时常量池
  • class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool ),用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic References)
    • 字面量包括:1.文本字符串 2.八种基本类型的值 3.被声明为final的常量等;
    • 符号引用包括:1.类的全限定名,2.字段名和属性,3.方法名和属性。
  • 运行时常量池在哪:https://wwwblogs/cosmos-wong/p/12925299.html 。总结:JDK6时在方法区永久代中,JDK7时在堆中
  • 栈帧里的动态链接连接到了运行时常量池。比如要找一个方法,首先找#id序号,根据id找到对应的字符串,再根据字符串的类型找到对应的方法或者常量等信息

jvm在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。

不同之处是:它的字面量可以动态的添加(String#intern()),符号引用可以被解析为直接引用(里面的符号地址变为真实地址)。

运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中,也就是说,【每个class都有一个运行时常量池,class常量池被加载到内存之后的版本】,类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。解析的过程会去查询全局字符串池,也就是我们下面所说的StringTable,以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入运行时常量池中,这种特性被开发人员利用比较多的就是String类的intern()方法

  • 运行时常量池:
/**1.8 以前会导致永久代内存溢出
 * 演示永久代内存溢出  java.lang.OutOfMemoryError: PermGen space
 * -XX:MaxPermSize=8m
 */
public class Demo1_8 extends ClassLoader {//可以用来加载类的二进制字节码
    public static void main(String[] args) {
        int j = 0;
        try {
            Demo1_8 test = new Demo1_8();
            for (int i = 0; i < 20000; i++, j++) {
                ClassWriter cw = new ClassWriter(0);//ClassWriter作用是生成类的二进制字节码
                cw.visit(Opcodes.V1_6, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);//参数:类版本号、类的访问修饰符、类的名字、包名类的父类、类要实现的接口
                byte[] code = cw.toByteArray();//返回byte数组
                test.defineClass("Class" + i, code, 0, code.length);//触发类的加载//即生成了Class对象
            }
        } finally {
            System.out.println(j);
        }
    }
}
/**1.8之后会导致元空间内存溢出
 * 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace 元空间
 * -XX:MaxMetaspaceSize=8m
 */
public class Demo1_8 extends ClassLoader { // 可以用来加载类的二进制字节码
    public static void main(String[] args) {
        int j = 0;
        try {
            Demo1_8 test = new Demo1_8();
            for (int i = 0; i < 10000; i++, j++) {
                // ClassWriter 作用是生成类的二进制字节码
                ClassWriter cw = new ClassWriter(0);
                // 版本号, public, 类名, 包名, 父类, 接口
                cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
                // 返回 byte[]
                byte[] code = cw.toByteArray();
                // 执行了类的加载
                test.defineClass("Class" + i, code, 0, code.length); // Class 对象
            }
        } finally {
            System.out.println(j);
        }
    }
}
主要内容
字段信息
  • 声明的顺序
  • 修饰符
  • 类型
  • 名字
方法信息
  • 声明的顺序
  • 修饰符
  • 返回值类型
  • 名字
  • 参数列表(有序保存)
  • 异常表(方法抛出的异常)
  • 方法字节码(native、abstract方法除外,)
  • 操作数栈和局部变量表大小
类变量(即static变量)

非final类变量

在java虚拟机使用一个类之前,它必须在方法区中为每个非final类变量分配空间。非final类变量存储在定义它的类中;

final类变量(不存储在这里)

由于final的不可改变性,因此,final类变量的值在编译期间,就被确定了,因此被保存在类的常量池里面,然后在加载类的时候,复制进方法区的运行时常量池里面 ;final类变量存储在运行时常量池里面,每一个使用它的类保存着一个对其的引用;

对类加载器的引用

jvm必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么jvm会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。

对Class类的引用

jvm为每个加载的类都创建一个java.lang.Class的实例(存储在堆上)。而jvm必须以某种方式把Class的这个实例和存储在方法区中的类型数据(类的元数据)联系起来, 因此,类的元数据里面保存了一个Class对象的引用;

方法表

为了提高访问效率,必须仔细的设计存储在方法区中的数据信息结构。除了以上讨论的结构,jvm的实现者还可以添加一些其他的数据结构,如方法表。jvm对每个加载的非虚拟类的类型信息中都添加了一个方法表,方法表是一组对类实例方法的直接引用(包括从父类继承的方法。jvm可以通过方法表快速激活实例方法。(译者:这里的方法表与C++中的虚拟函数表一样,但java方法全都 是virtual的,自然也不用虚拟二字了。正像java宣称没有 指针了,其实java里全是指针。更安全只是加了更完备的检查机制,但这都是以牺牲效率为代价的,个人认为java的设计者 始终是把安全放在效率之上的,所有java才更适合于网络开发)

intern()
  • 作用:将指定字符串尝试放入StringTable

调用str1.intern()

  • 如果常量池中已经有了该字符串str1,那直接返回常量池中str1的引用。(注意返回的跟去时候的可能没有关系)
  • 如果常量池中没有该字符串str1,
    • JDK6会把字符串复制到常量池中,相当于常量池中是一个副本str2,并且返回的是该副本的引用str2,而该字符串str1还是指向堆中;
    • JDK7会把堆中字符串str1的引用写到常量池中str1,而不是复制,当新的变量被赋值该字符串str1时,直接指向的是该引用str1。如果原来就有同样的内容了,就返回原来内容我引用
此外String对象调用intern()方法时,会先在StringTable中查找是否存在于该对象相同的字符串,若存在直接返回String table中字符串的引用,若不存在则在StringTable中创建一个与该对象相同的字符串。


String s1 = "ma";
String s2 = "in";
String s3 = s1 +s2;//实际上指向的是堆
s3.intern();//main String,java等属于关键词,在一开始就在StringTable中存在了,所以s3.intern没能插入进去。
String s4 = "ma" + "in";
System.out.println(s3 == s4);//false

javap -v hello.class

  • v 显示反编译后的详细信息

常量池中的字符串仅是符号,第一次用到时才变为对象。利用串池的机制,来避免重复创建字符串对象

字符串常量池

字符串常量池在方法区中,1.8中使用原空间代替永久代来实现方法区,但方法区并没有改变。改变的是方法去中内容的物理存放位置。类信息被移动到元空间中,但运行时常量池和字符串常量池被移动到了堆中。但是不论他们物理上如何存放,逻辑上还是属于方法区的。

一、new String都是在堆上创建字符串对象。当调用 intern() 方法时,编译器会将字符串添加到常量池中(stringTable维护),并返回指向该常量的引用。

String s = new String("abc");//字符串常量池中有abc,堆中也有
String s1 = "abc";
String s2 = new String("abc");
System.out.println(s == s1.intern());//false
System.out.println(s == s2.intern());//false
System.out.println(s1 == s.intern());//true //intern返回的是并不是s了,而是常量池中的s1了
System.out.println(s1 == s2.intern());//true

二、通过字面量赋值创建字符串(如:String str=”twm”)时,会先在常量池中查找是否存在相同的字符串,若存在,则将栈中的引用直接指向该字符串;若不存在,则在常量池中生成一个字符串,再将栈中的引用指向该字符串。

三、常量字符串的“+”操作,编译阶段直接会合成为一个字符串。如string str=“JA”+“VA”,在编译阶段会直接合并成语句String str=“JAVA”,于是会去常量池中查找是否存在”JAVA”,从而进行创建或引用。

四、对于final字段,编译期直接进行了常量替换(而对于非final字段则是在运行期进行赋值处理的)。

String s1 = "bc";
final String s2 = "b";//注意是final,即是常量
final String s3 = "c";
String s4 = s2 + s3;// 在编译时,直接替换成了String s4="b"+"c",根据第三条规则,再次替换成String s4="bc"
// 常量的时候编译后就是符号,可以理解为不是变量
System.out.println(s1 == s4);//true,因为final变量在编译后会直接替换成对应的值,所以实际上等于s4="b"+"c",而这种情况下,编译器会直接合并为s4="bc",所以最终s1==s4。

五、常量字符串和变量拼接时(如:String str3=baseStr + "01";)会调用==stringBuilder.append()在堆上创建新的对象==。

六、JDK 1.7后,intern方法还是会先去查询常量池中是否有已经存在,如果存在,则返回常量池中的引用,这一点与之前没有区别,

区别在于,如果在常量池找不到对应的字符串,则不会再将字符串拷贝到常量池,而只是在常量池中生成一个对原字符串的引用。简单的说,就是往常量池放的东西变了:原来在常量池中找不到时,复制一个副本放到常量池,1.7后则是将在堆上的地址引用复制到常量池。

举例说明:

String str2 = new String("str")+new String("01");
str2.intern();//JDK6:复制一份,返回该副本引用,但str2还是指向堆中的。JDK7:在常量池中生成一个引用指向堆中。

String str1 = "str01";//JDK6:常量池中的副本。JDK7:这个引用从字符串常量池中指向堆中
System.out.println(str2==str1);//JDK6:false。JDK7:true

在JDK 1.7下,当执行str2.intern();时,因为常量池中没有“str01”这个字符串,所以会在常量池中生成一个对堆中的“str01”的引用(注意这里是引用 ,就是这个区别于JDK 1.6的地方。在JDK1.6下是生成原字符串的拷贝),而在进行String str1 = “str01”;字面量赋值的时候,常量池中已经存在一个引用,所以直接返回了该引用,因此str1和str2都指向堆中的同一个字符串,返回true。

String str2 = new String("str")+new String("01");//JDK6:堆//JDK7:堆
String str1 = "str01";//JDK6: 常量池//JDK7:常量池

str2.intern();//JDK6:尝试复制,常量池已经有了,没有复制,返回了常量池中的引用,但str2还是指向堆中的//JDK7:尝试提供堆中的引用给常量池,常量池已经有自己的了,无需引用堆中你的了
System.out.println(str2==str1);//JDK6:false//JDK7:false//都是堆中一份,常量池中一份

将中间两行调换位置以后,因为在进行字面量赋值(String str1 = “str01″)的时候,常量池中不存在,所以str1指向的常量池中的位置,而str2指向的是堆中的对象,再进行intern方法时,对str1和str2已经没有影响了,所以返回false。

String s = new String("1");//堆中,同时常量池中也有1了
s.intern();// JDK6,复制,复制失败 //JDK7:常量池中已经有了,无需复制
String s2 = "1";
System.out.println(s == s2);// JDK6和7都是false

String s3 = new String("1") + new String("1");//堆中有11,常量池中没有11
s3.intern();//JDK6复制成功//JDK7引用成功
String s4 = "11";

System.out.println(s3 == s4);//JDK6:false  JDK7:true

再分别调整上面代码2.3行、7.8行的顺序:

String s = new String("1");//堆中,同时常量池中也有1了
String s2 = "1";//指向上一句在常量池中创建好的常量,但不是堆中的常量
s.intern();

System.out.println(s == s2);//JDK6:false  JDK7:false

 
String s3 = new String("1") + new String("1");//堆中有11,常量池中没有11
String s4 = "11";//常量池中也自己的有11了
s3.intern();//JDK6复制失败//JDK7引用失败

System.out.println(s3 == s4);//JDK6:false  JDK7:false
字符串常量池的位置
  • JDK6:StringTable在方法区。
  • JDK7:StringTable在堆区
//运行如下代码探究常量池的位置  
public static void main(String[] args) throws Throwable {  
    List<String> list = new ArrayList<String>();  
    int i=0;  
    while(true){  
        list.add(String.valueOf(i++).intern());  
    }  
}  
/*
用jdk1.6运行后会报错,永久代这个区域内存溢出会报:
Exception in thread “main” java.lang.OutOfMemoryError:PermGen space的内存溢出异常,表示永久代内存溢出。
jdk1.7 和1.8Exception in thread “main” java.lang.OutOfMemoryError: Java heap space说明1.6在永久带,1.7以后移动到了heap中
98%的垃圾回收,但只有2%的堆被重置
*/
串常量垃圾回收
package JVMtest;
/*
* 演示stringTable垃圾回收
* -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
* 打印字符串表的统计信息
* 打印垃圾回收的详细信息
* */
public class StringTable {
    public static void main(String[] args) {
        int i=0;
        try {
            for(int j=0;j<100;j++){//ctrl+alt+t快捷键try catch
                String.valueOf(j).intern();//这句话注释与打开
                i++;
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            System.out.println(i);
        }
    }
}
//输出信息如下:

100
Heap//堆
 PSYoungGen      total 2560K, used 1644K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 2048K, 80% used [0x00000000ffd00000,0x00000000ffe9b3f0,0x00000000fff00000)
  from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
  to   space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
 ParOldGen       total 7168K, used 0K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 0% used [0x00000000ff600000,0x00000000ff600000,0x00000000ffd00000)
 Metaspace       used 3144K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 343K, capacity 388K, committed 512K, reserved 1048576K
SymbolTable statistics://符号表
Number of buckets       :     20011 =    160088 bytes, avg   8.000
Number of entries       :     13114 =    314736 bytes, avg  24.000
Number of literals      :     13114 =    562744 bytes, avg  42.912
Total footprint         :           =   1037568 bytes
Average bucket size     :     0.655
Variance of bucket size :     0.655
Std. dev. of bucket size:     0.810
Maximum bucket size     :         6
StringTable statistics://串常量分析
Number of buckets       :     60013 =    480104 bytes, avg   8.000//桶个数60013
Number of entries       :      1839 =     44136 bytes, avg  24.000//键值对个数1839
Number of literals      :      1839 =    161288 bytes, avg  87.704//字符串常量个数//什么都没做就有1000+了//注释了for之后显示1739
Total footprint         :           =    685528 bytes
Average bucket size     :     0.031
Variance of bucket size :     0.031
Std. dev. of bucket size:     0.175
Maximum bucket size     :         3

Process finished with exit code 0

//for改为10000后,//字符串常量并没有变为10000多个,而是满了之后就垃圾回收了。证明了StringTable也会发生垃圾回收
[GC (Allocation Failure) [PSYoungGen: 2048K->488K(2560K)] 2048K->636K(9728K), 0.0012292 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
10000
StringTable statistics:
Number of buckets       :     60013 =    480104 bytes, avg   8.000
Number of entries       :      3174 =     76176 bytes, avg  24.000
Number of literals      :      3174 =    225688 bytes, avg  71.105
Total footprint         :           =    781968 bytes
串常量池性能调优
  • 调整:XX:+StringTableSize=桶个数。将StringTable中的桶个数设为2000。 hash表桶的数量越多(数组部分长度越长),数据越分散,hashcode撞车的概率越小,速度越快。 默认值是6万多
  • 考虑将字符串对象是否入池
-Xms500m 设置堆内存为500mb
    -Xmx500m -XX:+PrintStringTableStatistics -XX:+StringTableSize=20000
    限制了桶大小为2W。
    变慢了
    往StringTable里放一个字符串,就要去哈希表里查找有没有。有就不能放进去。
 public static void main(String[] args) {
        try {
            BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(new File("f:\\test.txt"))));
            String line = null;
            long start = System.nanoTime();
            while (true) {
                line = reader.readLine();
                if (line == null) {
                    break;
                }
                line.intern();
            }
            System.out.println("cost:" + (System.nanoTime() - start) / 1000000);
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

//通过读取文件将文件中的每一行逐行加入到StringTable中,修改桶的大小来测试所需要的时间(文件为8145行)

StringTableSizeTime
128172 ms
1024116 ms
409687 ms
JDK6

在JDK1.6中所有的输出结果都是 false,因为JDK1.6以及以前版本中,常量池是放在 Perm 区(属于方法区)中的,熟悉JVM的话应该知道这是和堆区完全分开的。

使用引号声明的字符串都是会直接在字符串常量池中生成的,而 new 出来的 String 对象是放在堆空间中的。所以两者的内存地址肯定是不相同的,即使调用了intern()方法也是不影响的。

intern()方法在JDK1.6中的作用是:比如String s = new String(“SEU_Calvin”),再调用s.intern(),此时返回值还是字符串"SEU_Calvin",表面上看起来好像这个方法没什么用处。但实际上,在JDK1.6中它做了个小动作:检查字符串池里是否存在"SEU_Calvin"这么一个字符串,如果存在,就返回池里的字符串;如果不存在,该方法会把"SEU_Calvin"添加到字符串池中,然后再返回它的引用。然而在JDK1.7中却不是这样的,后面会讨论。

JDK7

针对JDK1.7以及以上的版本,我们将上面两段代码分开讨论。先看第一段代码的情况:

**

String s = new String("1");//生成了常量池中的“1” 和堆空间中的字符串对象
s.intern();// s对象去常量池中寻找后发现"1"已经存在于常量池中了。
String s2 = "1";//生成一个s2的引用指向常量池中的“1”对象。
System.out.println(s == s2);// JDK6和7都是false

String s3 = new String("1") + new String("1");//在字符串常量池中生成“1” ,并在堆空间中生成s3引用指向的对象(内容为"11")。注意此时常量池中是没有 “11”对象的。
s3.intern();//将 s3中的“11”字符串放入 String 常量池中,此时常量池中不存在“11”字符串,JDK1.6的做法是直接在常量池中生成一个 "11" 的对象。
//但是在JDK1.7中,常量池中不需要再存储一份对象了,可以直接存储堆中的引用。这份引用直接指向 s3 引用的对象,也就是说s3.intern() ==s3会返回true。
String s4 = "11";//直接去常量池中创建,但是发现已经有这个对象了,此时也就是指向 s3 引用对象的一个引用。因此s3 == s4返回了true。

System.out.println(s3 == s4);//JDK6:false  JDK7:true

下面继续分析第二段代码:

再把第二段代码贴一下便于查看:

String s = new String("1");//生成了常量池中的“1” 和堆空间中的字符串对象。
String s2 = "1";//这行代码是生成一个s2的引用指向常量池中的“1”对象,但是发现已经存在了,那么就直接指向了它。
s.intern();//这一行在这里就没什么实际作用了。因为"1"已经存在了。

System.out.println(s == s2);// 引用地址不同//JDK6:false  JDK7:false

 
String s3 = new String("1") + new String("1");//在字符串常量池中生成“1” ,并在堆空间中生成s3引用指向的对象(内容为"11")。注意此时常量池中是没有 “11”对象的。
String s4 = "11";//直接去生成常量池中的"11"。
s3.intern();//这一行在这里就没什么实际作用了。因为"11"已经存在了。

System.out.println(s3 == s4);//引用地址不同//JDK6:false  JDK7:false
String str1 = new String("SEU") + new String("Calvin");

System.out.println(str1.intern() == str1);//JDK6:false//JDK7:true

System.out.println(str1 == "SEUCalvin");//JDK6:false//JDK7:true
String str2 = "SEUCalvin";//新加的一行代码,其余不变

String str1 = new String("SEU")+ new String("Calvin");

System.out.println(str1.intern() == str1);//JDK6:false//JDK7:false

System.out.println(str1 == "SEUCalvin");//JDK6:false//JDK7:false

也很简单啦,str2先在常量池中创建了“SEUCalvin”,那么str1.intern()当然就直接指向了str2,你可以去验证它们两个是返回的true。后面的"SEUCalvin"也一样指向str2。所以谁都不搭理在堆空间中的str1了,所以都返回了false。

new String()究竟创建几个对象?

由来

遇到一个Java面试题,是关于String的,自己对String还有点研究?下面是题目的描述:

在Java中,new String("hello")这样的创建方式,到底创建了几个String对象?

解答

题目中的String创建方式,是调用String的有参构造函数,而这个有参构造函数的源码则是这样的public String(String original),这就是说,我们可以把代码转换为下面这种:

String temp = "hello";  // 在常量池中
String str = new String(temp); // 在堆上

这段代码就创建了2个String对象,temp指向在常量池中的,str指向堆上的,而str内部的char value[]则指向常量池中的char value[],所以这里的答案是2个对象。(这里不再详述内部过程,之前的文章有写,参考深入浅出Java String)

那之前我为什么说答案是1个的也对呢,假如就只有这一句String str = new String("hello")代码,并且此时的常量池的没有"hello"这个String,那么答案是两个;如果此时常量池中,已经存在了"hello",那么此时就只创建堆上str,而不会创建常量池中temp,(注意这里都是引用),所以此时答案就是1个。

《深入理解java虚拟机》第二版 57页

对String.intern()返回引用的测试代码如下:

String str1 = new StringBuilder("计算机").append("软件").toString();
// String str3= new StringBuilder("计算机软件").toString();
System.out.println(str1.intern() == str1);//JDK6:false//JDK7:true

String str2 = new StringBuilder("Java(TM) SE ").append("Runtime Environment").toString();
;//堆中有,问题是常量池中在intern之前是否有拼接完的字符串
System.out.println(str2.intern() == str2);//JDK6:false//JDK7:false
//jdk6因为是复制,所以不可能相等,问题是jdk7可能是引用,按理说应该是true,为什么是false
//这个因为jdk源码中已经有了这个拼接完的字符串,在标注版本的时候定义过了

可能很多人觉得这个结果很奇怪,在这里我们进行深入地探究。

  • 因为JDK1.6中,intern()方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串的实例的引用,而StringBulder创建的字符串实例在Java堆上,所以必然不是同一个引用,将返回false。
  • 在JDK1.7中,intern()的实现不会在复制实例,只是在常量池中记录首次出现的实例引用,因此返回的是引用和由StringBuilder.toString()创建的那个字符串实例是同一个。

str2的比较返回false因为"java"这个字符串在执行StringBuilder.toString()之前已经出现过,字符串常量池中已经有它的引用了,不符合“首次出现”的原则,而“计算机软件”这个字符串是首次出现,因此返回true。

那么就有疑问了,这个“java”字符串在哪里出现过呢?显然并不是直接出现在这个类里面。

我们分别打开String 、StringBuilder和System类的源码看看有啥发现,

其中在System类里发现

有java版本的字符串

因此sun.misc.Version 类会在JDK类库的初始化过程中被加载并初始化,而在初始化时它需要对静态常量字段根据指定的常量值(ConstantValue)做默认初始化,此时被 sun.misc.Version.launcher 静态常量字段所引用的"java"字符串字面量就被intern到HotSpot VM的字符串常量池——StringTable里了。

因此我们修改一下代码:

  1. String str2 = new StringBuilder("Java(TM) SE ").append("Runtime Environment").toString();
  2. System.out.println(str2.intern() == str2)

发现结果还是false

从而更加证实了我们的猜测。

再遇到类似问题的时候,希望大家可以多从源码角度去追本溯源,能够多分享出来。

https://wwwblogs/clamp7724/p/11751278.html

字符串常量池:String table又称为String pool,

  • 字符串常量池在Java内存区域的哪个位置
    • 在JDK6.0及之前版本,字符串常量池是放在【Perm Gen区(也就是方法区)】中;
    • 在JDK7.0版本,字符串常量池被移到了【堆】中了。至于为什么移到堆内,大概是由于方法区的内存空间太小了。但是字符串常量池与堆对象还是不一样
  • 字符串常量池放的是什么:
    • 在JDK6.0及之前版本中,String Pool里放的都是字符串常量
    • 在JDK7.0中,由于String#intern()发生了改变,因此String Pool中也可以存放放于堆内的字符串对象的引用
  • StringTable还存在一个hash表的特性∶里面不存在相同的两个字符串。
  • main String,java等属于关键词,在一开始就在StringTable中存在了,所以str.intern没能插入进去。
String s1 = "ha";
String s2 = "ha";
String s3 = s1 +s2;//s3本质调用了 new StringBuilder.append("a").append("b").toString(); 声明了新的引用变量,开辟了新的空间,所以指向的是堆中的对象地址而不是StringTable中的字符串了。
String s4 = "ha" + "ha";//因为是两个常量拼接,在编译时就会直接变成"haha"进行处理,进入StringTable
String s5 = "haha";//因为也是常量,会先在StringTable中查找,找到后s5指向了StringTable中的"haha"
String s6 = new String("haha");
System.out.println(s3 == s4);//false
System.out.println(s4 == s5);//true
System.out.println(s5 == s6);//false

3.6_直接内存

在JAVA中,JVM内存指的是堆内存。

机器内存中,不属于堆内存的部分即为堆外内存。

堆外内存也被称为直接内存。

内存对象分配在Java虚拟机的堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机),这样做的结果就是能够在一定程度上减少垃圾回收对应用程序造成的影响。使用未公开的Unsafe和NIO包下ByteBuffer来创建堆外内存。

堆内内存是属于jvm的,由jvm进行分配和管理,属于"用户态",而堆外内存是由操作系统管理的,属于"内核态"。

在jdk1.4中新加入了NIO类,他可以调用native函数库直接分配堆外内存,然后通过java堆中的DirectByteBuffer对象来指向这块内存,进行内存分配等工作

直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中定义的内存区域。在JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用native 函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

JAVA通过调用ByteBuffer.allocateDirect及 MappedByteBuffer 来进行内存分配。不过JVM对Direct Memory可申请的大小也有限制,可用-XX:MaxDirectMemorySize=1M设置,这部分内存不受JVM垃圾回收管理。

  • 堆外内存:Direct Memory,也叫堆外内存。这部分内存不是由jvm管理和回收的。需要我们手动的回收。
  • 常见于 NIO 操作时,用于数据缓冲区
  • 分配回收成本较高,但读写性能高
  • 不受 JVM 内存回收管理

为什么使用堆外内存:

  • 1、减少了垃圾回收:使用堆外内存的话,堆外内存是直接受操作系统管理( 而不是虚拟机 )。这样做的结果就是能保持一个较小的堆内内存,以减少垃圾收集对应用的影响。
  • 2、提升复制速度(io效率):堆内内存由JVM管理,属于“用户态”;而堆外内存由OS管理,属于“内核态”。如果从堆内向磁盘写数据时,数据会被先复制到堆外内存,即内核缓冲区,然后再由OS写入磁盘,使用堆外内存避免了这个操作。(不需要经过对内)

堆外内存申请:

  • JDK的ByteBuffer类提供了一个接口allocateDirect(int capacity)进行堆外内存的申请,底层通过unsafe.allocateMemory(size)实现。Netty、Mina等框架提供的接口也是基于ByteBuffer封装的。

堆外内存释放:

  • unsafe.allocateMemory(size)最底层是通过malloc方法申请的,但是这块内存需要进行手动释放,JVM并不会进行回收,幸好Unsafe提供了另一个接口freeMemory可以对申请的堆外内存进行释放。

当初始化一块堆外内存时,对象的引用关系如下:

《JAVA对象引用》叫告诉了我们有ReferenceQueue引用监视器。

当一个 DirectByteBuffer初始化的时候,都会创建cleaner对象( 继承PhantomReference)并把 其注册进ReferenceQueue中。

当DirectByteBuffer=null的时候,如果引用在放入PhantomReference过程中,JVM就会调用cleaner.clean 并放弃通知ReferenceQueue。

其中firstCleaner类的静态变量,Cleaner对象在初始化时会被添加到Clenear链表中,和first形成引用关系,ReferenceQueue是用来保存需要回收的Cleaner对象。

如果该DirectByteBuffer对象在一次GC中被回收了

此时,只有Cleaner对象唯一保存了堆外内存的数据(开始地址、大小和容量),在下一次FGC时,把该Cleaner对象放入到ReferenceQueue中,并触发clean方法。

Cleaner对象的clean方法主要有两个作用:
1、把自身从Cleaner链表删除,从而在下次GC时能够被回收
2、释放堆外内存

如果JVM一直没有执行FGC的话,无效的Cleaner对象就无法放入到ReferenceQueue中,从而堆外内存也一直得不到释放,内存岂不是会爆?

其实在初始化DirectByteBuffer对象时,如果当前堆外内存的条件很苛刻时,会主动调用System.gc()强制执行FGC。

Unsafe类操作堆外内存

sun.misc.Unsafe提供了一组方法来进行堆外内存的分配,重新分配,以及释放。

// 分配一块内存空间。
public native long allocateMemory(long size);
//重新分配一块内存,把数据从address指向的缓存中拷贝到新的内存块。
public native long reallocateMemory(long address, long size);
//释放内存。
public native void freeMemory(long address);

参考:Unsafe类操作JAVA内存

public static void main(String[] args) {
    Unsafe unsafe = new Unsafe();
    unsafe.allocateMemory(1024);
}

然而Unsafe类的构造器是私有的,报错。

而且,allocateMemory方法也不是静态的,不能通过Unsafe.allocateMemory调用。

幸运的是可以通过Unsafe.getUnsafe()取得Unsafe的实例。

public static void main(String[] args) {
    Unsafe unsafe = Unsafe.getUnsafe();
    unsafe.allocateMemory(1024);
    unsafe.reallocateMemory(1024, 1024);
    unsafe.freeMemory(1024);
}

此外,也可以通过反射获取unsafe对象实例

参考:危险代码:如何使用Unsafe操作内存中的Java类和对象

NIO类操作堆外内存

用NIO包下的ByteBuffer分配直接内存则相对简单。

ByteBuffer buffer = ByteBuffer.allocateDirect(10 * 1024 * 1024);

然而运行时报错了。

java(51146,0x7000023ed000) malloc: *** error for object 0x400: pointer being realloc’d was not allocated
*** set a breakpoint in malloc_error_break to debug

错误信息

参考:JAVA堆外内存

然而在小伙伴的电脑上跑这段的代码是可以成功运行的。

二:堆外内存垃圾回收

对于内存,除了关注怎么分配,还需要关注如何释放。

从JAVA出发,习惯性思维是堆外内存是否有垃圾回收机制。

考虑堆外内存的垃圾回收机制,需要了解以下两个问题:

  1. 堆外内存会溢出么?
  2. 什么时候会触发堆外内存回收?

问题一

通过修改JVM参数:-XX:MaxDirectMemorySize=40M,将最大堆外内存设置为40M。

既然堆外内存有限,则必然会发生内存溢出。

为模拟内存溢出,可以设置JVM参数:-XX:+DisableExplicitGC,禁止代码中显式调用System.gc()。

可以看到出现OOM。

得到的结论是,堆外内存会溢出,并且其垃圾回收依赖于代码显式调用System.gc()。

参考:JAVA堆外内存

问题二

关于堆外内存垃圾回收的时机,首先考虑堆外内存的分配过程。

JVM在堆内只保存堆外内存的引用,用DirectByteBuffer对象来表示。

每个DirectByteBuffer对象在初始化时,都会创建一个对应的Cleaner对象。

这个Cleaner对象会在合适的时候执行unsafe.freeMemory(address),从而回收这块堆外内存。

当DirectByteBuffer对象在某次YGC中被回收,只有Cleaner对象知道堆外内存的地址。

当下一次FGC执行时,Cleaner对象会将自身Cleaner链表上删除,并触发clean方法清理堆外内存。

此时,堆外内存将被回收,Cleaner对象也将在下次YGC时被回收。

如果JVM一直没有执行FGC的话,无法触发Cleaner对象执行clean方法,从而堆外内存也一直得不到释放。

其实,在ByteBuffer.allocateDirect方式中,会主动调用System.gc()强制执行FGC。

JVM觉得有需要时,就会真正执行GC操作。

显式调用

参考:堆外内存的回收机制分析—占小狼

三:为什么用堆外内存?

堆外内存的使用场景非常巧妙。

第三方堆外缓存管理包ohc(off-heap-cache)给出了详细的解释。

摘了其中一段。

When using a very huge number of objects in a very large heap, Virtual machines will suffer from increased GC pressure since it basically has to inspect each and every object whether it can be collected and has to access all memory pages. A cache shall keep a hot set of objects accessible for fast access (e.g. omit disk or network roundtrips). The only solution is to use native memory - and there you will end up with the choice either to use some native code (C/C++) via JNI or use direct memory access.

大概的意思如下:

考虑使用缓存时,本地缓存是最快速的,但会给虚拟机带来GC压力。

使用硬盘或者分布式缓存的响应时间会比较长,这时候「堆外缓存」会是一个比较好的选择。

参考:OHC - An off-heap-cache — Github

四:如何用堆外内存?

在第一章中介绍了两种分配堆外内存的方法,Unsafe和NIO。

对于两种方法只是停留在分配和回收的阶段,距离真正使用的目标还很遥远。

在第三章中提到堆外内存的使用场景之一是缓存。

那是否有一个包,支持分配堆外内存,又支持KV操作,还无需关心GC。

答案当然是有的。

有一个很知名的包,Ehcache

Ehcache被广泛用于Spring,Hibernate缓存,并且支持堆内缓存,堆外缓存,磁盘缓存,分布式缓存。

此外,Ehcache还支持多种缓存策略。

其仓库坐标如下:

<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <version>3.4.0</version>
</dependency>

接下来就是写代码进行验证:

public class HelloHeapServiceImpl implements HelloHeapService {

    private static Map<String, InHeapClass> inHeapCache = Maps.newHashMap();

    private static Cache<String, OffHeapClass> offHeapCache;

    static {
        ResourcePools resourcePools = ResourcePoolsBuilder.newResourcePoolsBuilder()
                .offheap(1, MemoryUnit.MB)
                .build();

        CacheConfiguration<String, OffHeapClass> configuration = CacheConfigurationBuilder
                .newCacheConfigurationBuilder(String.class, OffHeapClass.class, resourcePools)
                .build();

        offHeapCache = CacheManagerBuilder.newCacheManagerBuilder()
                .withCache("cacher", configuration)
                .build(true)
                .getCache("cacher", String.class, OffHeapClass.class);


        for (int i = 1; i < 10001; i++) {
            inHeapCache.put("InHeapKey" + i, new InHeapClass("InHeapKey" + i, "InHeapValue" + i));
            offHeapCache.put("OffHeapKey" + i, new OffHeapClass("OffHeapKey" + i, "OffHeapValue" + i));
        }
    }

    @Data
    @AllArgsConstructor
    private static class InHeapClass implements Serializable {
        private String key;
        private String value;
    }

    @Data
    @AllArgsConstructor
    private static class OffHeapClass implements Serializable {
        private String key;
        private String value;
    }

    @Override
    public void helloHeap() {
        System.out.println(JSON.toJSONString(inHeapCache.get("InHeapKey1")));
        System.out.println(JSON.toJSONString(offHeapCache.get("OffHeapKey1")));
        Iterator iterator = offHeapCache.iterator();
        int sum = 0;
        while (iterator.hasNext()) {
            System.out.println(JSON.toJSONString(iterator.next()));
            sum++;
        }
        System.out.println(sum);
    }
}

其中.offheap(1, MemoryUnit.MB)表示分配的是堆外缓存。

Demo很简单,主要做了以下几步操作:

  1. 新建了一个Map,作为堆内缓存。
  2. 用Ehcache新建了一个堆外缓存,缓存大小为1MB。
  3. 在两种缓存中,都放入10000个对象。
  4. helloHeap方法做get测试,并统计堆外内存数量,验证先插入的对象是否被淘汰。

使用Java VisualVM工具Dump一个内存镜像。

Java VisualVM是JDK自带的工具。

工具位置如下:

/Library/Java/JavaVirtualMachines/jdk1.7.0_71.jdk/Contents/Home/bin/jvisualvm

也可以使用JProfiler工具。

打开镜像,堆里有10000个InHeapClass,却没有OffHeapClass,表示堆外缓存中的对象的确没有占用JVM内存。

内存镜像

接着测试helloHeap方法。

输出:

{“key”:“InHeapKey1”,“value”:“InHeapValue1”}
null
……(此处有大量输出)
5887

输出表示堆外内存启用了淘汰机制,插入10000个对象,最后只剩下5887个对象。

如果堆外缓存总量不超过最大限制,则可以顺利get到缓存内容。

总体而言,使用堆外内存可以减少GC的压力,从而减少GC对业务的影响。

import java.nio.ByteBuffer;

/**
 * 直接内存 与  堆内存的比较
 */
public class ByteBufferCompare {

    public static void main(String[] args) {
        allocateCompare();   //分配比较
        operateCompare();    //读写比较
    }

    /**
     * 直接内存 和 堆内存的 分配空间比较
     * 结论: 在数据量提升时,直接内存相比非直接内的申请,有很严重的性能问题
     */
    public static void allocateCompare(){
        int time = 10000000;    //操作次数                           


        long st = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            //ByteBuffer.allocate(int capacity)   分配一个新的字节缓冲区。
            ByteBuffer buffer = ByteBuffer.allocate(2);  //非直接内存分配申请     
        }
        long et = System.currentTimeMillis();

        System.out.println("在进行"+time+"次分配操作时,堆内存 分配耗时:" + (et-st) +"ms" );

        long st_heap = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            //ByteBuffer.allocateDirect(int capacity) 分配新的直接字节缓冲区。
            ByteBuffer buffer = ByteBuffer.allocateDirect(2); //直接内存分配申请
        }
        long et_direct = System.currentTimeMillis();

        System.out.println("在进行"+time+"次分配操作时,直接内存 分配耗时:" + (et_direct-st_heap) +"ms" );
    }

    /**
     * 直接内存 和 堆内存的 读写性能比较
     * 结论:直接内存在直接的IO 操作上,在频繁的读写时 会有显著的性能提升
     */
    public static void operateCompare(){
        int time = 1000000000;

        ByteBuffer buffer = ByteBuffer.allocate(2*time);  
        long st = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {

            //  putChar(char value) 用来写入 char 值的相对 put 方法
            buffer.putChar('a');
        }
        buffer.flip();
        for (int i = 0; i < time; i++) {
            buffer.getChar();
        }
        long et = System.currentTimeMillis();

        System.out.println("在进行"+time+"次读写操作时,非直接内存读写耗时:" + (et-st) +"ms");

        ByteBuffer buffer_d = ByteBuffer.allocateDirect(2*time);
        long st_direct = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {

            //  putChar(char value) 用来写入 char 值的相对 put 方法
            buffer_d.putChar('a');
        }
        buffer_d.flip();
        for (int i = 0; i < time; i++) {
            buffer_d.getChar();
        }
        long et_direct = System.currentTimeMillis();

        System.out.println("在进行"+time+"次读写操作时,直接内存读写耗时:" + (et_direct - st_direct) +"ms");
    }
}

原来的方案:

  • CPU:用户态java→内核态system→用户态java
  • 内存:磁盘文件放到系统内存中的系统缓冲区,然后再从系统缓存区转到java堆内存的java缓冲区byte[]

新方案:

增加了直接内存区域。

直接将磁盘文件放到直接内存中,不经过系统内存,而是新画出了一个直接内存区,java代码可以直接访问,系统也可以访问它。可以通过代码import java.nio.ByteBuffer; ByteBuffer.allocate(内存大小)申请直接内存区

分配和回收原理

package MM;

import java.nio.ByteBuffer;
//可以这样申请堆外内存 
public class Buffer {

    public static void main(String[] args) {

        while(true) {
            ByteBuffer.allocate(10*1024*1024);
        }
    }
}//运行结果:控制台无任何输出,也未结束。
//可以看到我们一直在申请内存,却一直没有内存溢出。直接内存被释放了。到底堆外内存(直接内存)是怎么释放的呢?(直接内存也会导致内存溢出)
//---------程序2------------
public class test {

    public static void main(String[] args) {

        ByteBuffer byteBuffer=ByteBuffer.allocateDirect(_1Gb);
        System.out.println("分配完毕");
        System.in.read();
        System.out.println("开始释放");
        byteBuffer=null;//后台显示释放成功
        System.gc();
        System.in.read();
    }
}
//-----------程序3---------
public class test {

    Static int _1Gb=1024*1024*1024;

    public static void main(String[] args) {

        Unsafe unsafe=getUnsafe();
        long base=unsafe.allocateMemoy(_1Gb);
        unsafe.setMemory(base,_1Gb,(byte)0);
        System.in.read();
        unsafe.freeMemory(base);
        System.in.read();
    }
    public static Unsafe getUnsafe(){
        try {
            Field f=Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            Unsafe unsafe=(Unsafe) f.get(null);
            return unsafe
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        }
    }
}

/*
NIO申请直接内存总结:

我们用NIO类申请的内存不受JVM的管理,但是其实是由jvm进行回收的,并不像unsave那样要我们自己对内存进行管理。这时候系统是不断回收直接内存的,由NIO申请的直接内存是需要System.gc()来进行内存回收的。系统会帮助我们回收直接内存的。

不过为了提高gc的利用率,我们可能会在代码中加入-XX:+DisableExplicit禁止代码中显示调用gc(System.gc)。采取并行gc,就是由jvm来自动管理内存回收,而jvm主要是管理堆内内存,也就是当对堆内对象回收的时候,才有可能回收直接内存,这种不对称性很有可能产生直接内存内存泄漏。

需要注意的是当我们没有指向堆外内存的引用的时候,也会把直接内存回收,这也是上面我们内存没有泄漏的原因。

采用直接内存的优点:

1:对于频繁的io操作,我们需要不断把内存中的对象复制到直接内存。然后由操作系统直接写入磁盘或者读出磁盘。

这时候用到直接内存就减少了堆的内外内存来回复制的操作。

2:我们在运行程序的过程中可能需要新建大量对象,对于一些声明周期比较短的对象,可以采用对象池的方式。但

是对于一些生命周期较长的对象来说,不需要频繁调用gc,为了节省gc的开销,直接内存是必备之选。

3:扩大程序运行的内存,由于jvm申请的内存有限,这时候可以通过堆外内存来扩大内存。

*/

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

JDK=jre+ development kit

JRE=jvm+ core lib

4、垃圾回收

如何判断对象可以回收:

  • 1 引用计数法:对象没有一个引用计算器,就+1。

    • 缺陷:循环引用。如AB对象互相引用,但没有其他对象引用AB对象时,AB对象本该回收却不能回收
  • 2 可达性分析(根搜索)算法:从根对象的点作为起始进行向下搜索,当一个对象到根对象没有任何引用链相连,则证明此对象是不可用的 。

    • 根对象GC Root:肯定不能被垃圾回收的对象。然后扫描堆中所有对象,判断是否被根对象直接或间接引用,如果引用了就不能回收。如果没有被直接/间接引用,就可以当做垃圾回收。
    • GC roots包括:
      • 在JVM栈(帧的局部变量)中的引用
      • static 域引用的对象
      • 方法区中常量引用的对象
      • JNI(即一般说的Native方法)中的引用
  • Memory Analyzer (MAT)堆分析工具:需要先使用jmap分析出堆内存,拿到快照,再由MAT进行分析。jmap -dump:format=b,live,file=1.bin 【进程号】。把bin文件导入MAT后,可以通过java Basics–GC Roots查看根对象。

    • dump:要把当前堆内存情况存储为一个文件
    • format=b:转出文件的格式,b表示二进制
    • live:只关心存活的,不关心垃圾回收的。自动在进行快照前会进行一次垃圾回收。
    • file=1.bin:文件名

1) 四种引用

https://www.jianshu/p/825cca41d962

我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。 很多系统的缓存功能都符合这样的应用场景。

我们把引用分为4种,用法如下

  • Strong:默认通过Object o=new Object();这种方式赋值的引用
  • Soft、Weak、Phantom:这三种则都是继承Reference。如SoftReference<byte[]> cacheRef = new SoftReference<>(4*1024*1024);

说明:

  • 强引用Strong:只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收。我们平时new的对象都是强引用。强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用 对象来解决内存不足的问题。
  • 软引用(SoftReference):仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收,回收软引用对象可以配合引用队列来释放软引用自身。软引用是用来描述一些还有用但并非必须的对象。软引用可用来实现内存敏感的高速缓存。
    • 软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中。
  • 弱引用(WeakReference):仅有弱引用引用该对象时(没有任何强引用关联他),在垃圾回收时,无论内存是否充足,都会回收弱引用对象。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。可以配合引用队列来释放弱引用自身。
  • 虚引用(PhantomReference):必须配合引用队列ReferenceQueue使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存。
    虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
    虚引用主要用来跟踪对象被垃圾回收的活动虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
  • 终结器引用(FinalReference):无需手动编码,但其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize 方法,第二次 GC 时才能回收被引用对象

在Full GC时会对Reference类型的引用进行特殊处理

  • Soft:内存不够时一定被GC,长期不用也会被GC

  • Weak:一定被GC,当做标记为dead,会在ReferenceQueue中通知。

  • Phantom:本来就没引用,当从jvm堆中释放时会通知。

软引用

import java.lang.ref.SoftReference;

// -Xmx20m -XX:+PrintGCDetails -verbose:gc
public class Ref {
    private static final int _4MB=4*1024*1024;

    public static void main(String[] args) {
        ArrayList<byte[]> list = new ArrayList<>();//强引用
        for (int i=0;i<5;i++){
            list.add(new byte[_4MB]);
        }
    }

    public static void soft(){

        ArrayList<SoftReference<Byte[]>> list = new ArrayList<>();//软引用
        for (int i = 0; i < 5; i++) {
            SoftReference<Byte[]> ref=new SoftReference<>(new Byte[_4MB]);
            System.out.println(ref.get());//正常显示
            list.add(ref);
            System.out.println(list.size());

        }//内存不够进行了垃圾回收,软引用垃圾回收后内容扔不足就会把软引用扔掉
        System.out.println("循环结束"+list.size());
        for (SoftReference<Byte[]> ref:list) {
            System.out.println(ref.get());//前4个都变为null
        }
    }
}
/*
[Ljava.lang.Byte;@1b6d3586
1
[Ljava.lang.Byte;@4554617c
2
[Ljava.lang.Byte;@74a14482
3
[Ljava.lang.Byte;@1540e19d
4
[Ljava.lang.Byte;@677327b6
5
循环结束:5
null
null
null
null
[Ljava.lang.Byte;@677327b6
*/

Ljava.lang.Byte;@1b6d3586
1
[Ljava.lang.Byte;@4554617c
2
[Ljava.lang.Byte;@74a14482
3
[GC (Allocation Failure) [PSYoungGen: 1819K->488K(6144K)] 14107K->12968K(19968K), 0.0020269 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] //调用了一次新生代垃圾回收,从1.8M回收到了0.4M
[Ljava.lang.Byte;@1540e19d
4
[GC (Allocation Failure) --[PSYoungGen: 4696K->4696K(6144K)] 17176K->17216K(19968K), 0.0023349 secs] [Times: user=0.08 sys=0.00, real=0.00 secs] //一次新生代垃圾回收,没回收多少
[Full GC (Ergonomics) [PSYoungGen: 4696K->4536K(6144K)] [ParOldGen: 12520K->12472K(13824K)] 17216K->17008K(19968K), [Metaspace: 3225K->3225K(1056768K)], 0.0069859 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] //一次FULL垃圾回收,还是没回收多少
[GC (Allocation Failure) --[PSYoungGen: 4536K->4536K(6144K)] 17008K->17008K(19968K), 0.0045728 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] //触发软连接垃圾回收
[Full GC (Allocation Failure) [PSYoungGen: 4536K->0K(6144K)] [ParOldGen: 12472K->606K(8704K)] 17008K->606K(14848K), [Metaspace: 3225K->3225K(1056768K)], 0.0054783 secs] [Times: user=0.08 sys=0.00, real=0.00 secs] //再一次FULL垃圾回收,回收了4M
[Ljava.lang.Byte;@677327b6
5
循环结束:5
null
null
null
null
[Ljava.lang.Byte;@677327b6

引用队列

 总结:
 软引用的list中有的为null了,但还没从list中清除掉。
 可以配合引用队列清楚。
 ArrayList<SoftReference<Byte[]>> list = new ArrayList<>();
 
 ReferenceQueue<byte[]> queue=new ReferenceQueue<>();//创建引用队列
 
 SoftReference<Byte[]> ref=new SoftReference<>(new Byte[_4MB],queue);//关联引用队列
 //当软引用所关联的的byte[]回收时,软引用自身就会被加入到queue中去。遍历时,就先到queue中查找,

 Reference<?extends byte[]> poll=queue.poll();//每次取一个
 while(poll!=null){
    list.remove(poll);
    poll=queue.poll()
}

弱引用

import java.lang.ref.WeakReference;

// 演示弱引用
// -Xmx20m -XX:+PrintGCDetails -verbose:gc
public class Weak {
    private static final int _4MB=4*1024*1024;

    public static void main(String[] args) {
        List<WeakReference<byte[]>> list= new ArrayList<>();//弱引用
        for (int i = 0; i < 5; i++) {
            WeakReference<byte[]> ref=new WeakReference<>(new byte[_4MB]);
            list.add(ref);
            System.out.println("第"+(i+1)+"次循环");
            for ( WeakReference<byte[]> w:list) {
                System.out.println(w.get()+"");
            }
            System.out.println();
        }
        System.out.println("循环结束:"+list.size());
    }
}


第1次循环//一个数组
[B@1b6d3586

第2次循环
[B@1b6d3586
[B@4554617c

第3次循环
[B@1b6d3586
[B@4554617c
[B@74a14482

[GC (Allocation Failure) [PSYoungGen: 1819K->488K(6144K)] 14107K->13016K(19968K), 0.0011633 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] //触发了一次GC
第4次循环//虽然GC了,但还存活
[B@1b6d3586
[B@4554617c
[B@74a14482
[B@1540e19d

[GC (Allocation Failure) [PSYoungGen: 4696K->488K(6144K)] 17224K->13016K(19968K), 0.0006622 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] //又一次GC
第5次循环
[B@1b6d3586
[B@4554617c
[B@74a14482
null //内存不够了,刚才GC清理了这个
[B@677327b6

循环结束:5
 //FULL GC

2) 垃圾回收算法

  • 1 标记、清除Mark-Sweep
  • 2 标记、整理Mark-Compact
  • 3 复制Copying
  • 分代Generational
1 标记+清除

标记:哪些对象可以当成垃圾(不被GC Root间接引用的)+清除(是否标记的那些空间)。没有需要就标记为不需要了。

缺点:效率不高,两个过程效率都不高。会造成内存碎片。空闲的区域都是小碎片,放不了大的对象。空间碎片太多可能会导致后续事宜中无法找到足够的连续内存而提前触发另一次垃圾搜集动作。

红色的对象应该被回收

2 标记+整理

清除碎片的过程中会把后面的对象往前移到可用的内存

定义:Mark Compact 没有内存碎片
缺点:速度慢

3 复制

将可用内存划分为两块(两块Survivor区 To和From),每次只使用其中的一块,当半区内存用完了,仅将还存活的对象复制到另外一块上面,然后就把原来半块内存空间一次性清除掉,整理过程。清掉整块的速度非常快,但是浪费内存,一半不可用

即新生代和老年代。不会有内存碎片
缺点:需要占用双倍内存空间,在对象存活率较高的时候,效率有所下降。如果不想浪费50%的空间,就需要有额外的空间进行分配担保用于应付半区内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。

3) 分代垃圾回收机制

https://blog.csdn/hollis_chuang/article/details/91349868

分为:新生代+老年代

  • 新生代:伊甸园Eden+幸存区From+幸存区To。Oracle Hotspot虚拟机默认比例是8:1:1。每次只有10%的内存是浪费的。可以通过-XX:SurvivorRatio=8调整,但是有自适应比较,可以通过-XX:-UseAdaptiveSizePolicy关掉。-Xmn可以设置新生代空间大小,但一般不设置Xmn
  • 老年代:经历N次垃圾回收都存活的对象
  • 新生代老年代默认比例:-XX:NewRatio=,(默认)老年代:新生代=2

思想:需要长时间使用的对象放到老年区。永远就可以丢弃的对象放到新生区中。

  • 对象首先分配在伊甸园区域
  • 新生代空间(伊甸园)不足时,触发 minor gc伊甸园和 from 中存活的对象复制到 to 中,存活的对象年龄+1,交换 from与to标识。当对象寿命超过==最大寿命是15(4bit)==阈值时,会晋升至老年代。当To区也满的时候也会放到老年代。
    • minor gc 会引发 stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。
  • 当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍不足,那么触发 full gc,对新生代和老年代全部区域做一次垃圾清理。STW(Stop the World)的时间更长
  • 老年区Full GC后还是不能保存对象,就触发内存不足OOM异常“OutOfMemoryError”。
  • 经历多次GC后,存活的对象会在From和To之间来回存放,而这里面的一个前提则是这两个空间有足够的大小来存放这些数据,在GC算法中,会计算每个对象年龄的大小,如果达到某个年龄后发现总大小已经大于了幸存区空间的50%,那么这是就需要调整阈值,不能再继续等到默认的15次GC后才晋升。因为这样会导致幸存区空间不足,所以需要调整阈值,让这些存活对象尽快完成晋升。
GC的时机:
  • ①Minor GC (YGC,Scavenge GC)
    • 触发时机:新对象生成时,新生代中Eden空间满了
    • 理论上Eden区大多数对象会在Minor GC回收,复制算法的执行效率会很高,Minor GC时机比较短
  • ②Full GC(Major GC)
    • 主要的触发时机:Old满了、Perm满了、system.gc()
    • 对整个JVM(新生代+老年代)进行整理,包括Young、Old和(Perm[JVM1.6之前])
    • 效率很低,尽量减少Full GC。Major GC 的速度一般会比 Minor GC 的慢 10 倍以上。

针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:

  • Partial GC:并不收集整个GC堆的模式

    • Young GC:只收集young gen的GC
    • Old GC:只收集old gen的GC。只有CMS的concurrent collection是这个模式
    • Mixed GC:收集整个young gen以及部分old gen的GC。只有G1有这个模式
  • Full GC:收集整个堆,包括young gen、old gen、perm gen(如果存在的话)等所有部分的模式。

Major GC通常是跟full GC是等价的,收集整个GC堆。但因为HotSpot VM发展了这么多年,外界对各种名词的解读已经完全混乱了,当有人说“major GC”的时候一定要问清楚他想要指的是上面的full GC还是old GC。

public class GC {
    private static final int _7MB=7*1024*1024;
    // -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
    //堆初始大小  堆最大大小20m  新生代大小10m
    public static void main(String[] args) {
        ArrayList<byte[]> list = new ArrayList<>();
        list.add(new byte[_7MB]);
    }
}

//下面的结果是main中什么都什么时的结果
Heap //堆 8M伊甸园,1From+1To
//新生代 9M,不计入幸存区
 def new generation   total 9216K, used 1814K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  22% used [0x00000000fec00000, 0x00000000fedc5868, 0x00000000ff400000)//伊甸园初试时候就有一些必要的类
  from space 1024K,   0% use d [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 //老年代
 tenured generation   total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,   0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
 //元空间
 Metaspace       used 3116K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 337K, capacity 388K, committed 512K, reserved 1048576K
//-------第二次运行--------添加了7M------
 //触发了一次minor GC 
//数字代表:[DefNew:回收前K->(总K),耗时]堆回收前->堆回收后(堆总大小),堆耗时
[GC (Allocation Failure) [DefNew: 1649K->594K(9216K), 0.0043403 secs] 1649K->594K(19456K), 0.0055600 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 8336K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  94% used [0x00000000fec00000, 0x00000000ff38f7b8, 0x00000000ff400000)
//放入To后,From和To交换了,所以这里的from是原来的To
  from space 1024K,  58% used [0x00000000ff500000, 0x00000000ff594980, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 tenured generation   total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,   0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
 Metaspace       used 3214K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 348K, capacity 388K, committed 512K, reserved 1048576K


大对象直接晋升到老年代:通过控制_XX:+PretenureSizeThreshold=,-XX:UserSerialGC

MaxTenuringThreshold的作用:在可以自动调节晋升到老年代的GC中,设置该阈值的最大值。该参数默认值位15,CMS中默认值为6,G1中默认值为15。-XX:MaxTenuringThreshold=,-XX:PrintTenuringDistribution

分配担保机制

简单解释一下为什么会出现这种情况: 因为给 allocation2 分配内存的时候 eden 区内存几乎已经被分配完了,我们刚刚讲了当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。GC 期间虚拟机又发现 allocation1 无法存入 Survivor 空间,所以只好通过 **分配担保机制:**把新生代的对象提前转移到老年代中去,老年代上的空间足够存放 allocation1,所以不会出现 Full GC。执行 Minor GC 后,后面分配的对象如果能够存在 eden 区的话,还是会在 eden 区分配内存。可以执行如下代码验证:

public class GCTest {

    public static void main(String[] args) {
        byte[] allocation1, allocation2,allocation3,allocation4,allocation5;
        allocation1 = new byte[32000*1024];
        allocation2 = new byte[1000*1024];
        allocation3 = new byte[1000*1024];
        allocation4 = new byte[1000*1024];
        allocation5 = new byte[1000*1024];
    }
}
大对象直接进入老年代:

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。

为什么要这样呢?

为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。

多线程分配技术TLABs:

我们知道在堆区分配对象,但是堆区是线程共享的,任何线程都可以访问到堆区中的共享数据

对于多线程应用,对象分配必须要保证线程安全性,如果使用全局锁,那么分配空间将成为瓶颈并降低程序性能。HotSpot 使用了称之为 Thread-Local Allocation Buffers (TLABs) 的技术,该技术能改善多线程空间分配的吞吐量。JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内(即对Eden区域继续进行划分)

首先,给予每个线程一部分内存作为缓存区,每个线程都在自己的缓存区中进行指针碰撞,这样就不用获取全局锁了。只有当一个线程使用完了它的 TLAB,它才需要使用同步来获取一个新的缓冲区。HotSpot 使用了多项技术来降低 TLAB 对于内存的浪费。比如,TLAB 的平均大小被限制在 Eden 区大小的 1% 之内。TLABs 和使用指针碰撞的线性分配结合,使得内存分配非常简单高效,只需要大概 10 条机器指令就可以完成。

指针碰撞:

如果垃圾收集完成后,存在大片连续的内存可用于分配给新对象,这种情况下分配空间是非常简单快速的,只要一个简单的指针碰撞就可以了(bump-the-pointer),每次分配对象空间只要检测一下是否有足够的空间,如果有,指针往前移动 N 位就分配好空间了,然后就可以初始化这个对象了。

据说所有OpenJDK衍生出来的JVM都提供了TLAB的设计。

每个线程有一份,使用完了再用公共的。默认是开启的

TLAB的再说明:

  • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是TLAB作为内存分配的首选。
  • 在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否开启TLAB空间。
  • 默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项“-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden空间的百分比大小。
  • 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间(应该是说的公共的)中分配内存。

垃圾收集器

前面我们讲了垃圾回收的算法,还需要有具体的实现,在jvm中,实现了多种垃圾收集器,包括:串行垃圾收集器、并行垃圾收集器、CMS(并发)垃圾收集器、G1垃圾收集器

>

  • (红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、 ParNew+Serial Old这两个组合声明为废弃(JEP 173) ,并在JDK 9中完全取消了这些组合的支持(JEP214),即:移除。
  • (绿色虚线)JDK 14中:弃用Parallel Scavenge和SerialOld GC组合(JEP366 )
  • (青色虚线)JDK 14中:删除CMS垃圾回收器 (JEP 363)
查看默认的垃圾收集器

方法1:-xx:+PrintCommandLineFlags: 查看命令行相关参数(包含使用的垃圾收集器)

方法2:使用命令行指令: jinfo -flag 相关垃圾回收器参数 进程ID

/**
 *  -XX:+PrintCommandLineFlags
 *
 *  -XX:+UseSerialGC:表明新生代使用Serial GC ,同时老年代使用Serial Old GC
 *
 *  -XX:+UseParNewGC:标明新生代使用ParNew GC
 *
 *  -XX:+UseParallelGC:表明新生代使用Parallel GC
 *  -XX:+UseParallelOldGC : 表明老年代使用 Parallel Old GC
 *  说明:二者可以相互激活
 *
 *  -XX:+UseConcMarkSweepGC:表明老年代使用CMS GC。同时,年轻代会触发对ParNew 的使用
 */
public class GCUseTest {
    public static void main(String[] args) {
        ArrayList<byte[]> list = new ArrayList<>();

        while(true){
            byte[] arr = new byte[100];
            list.add(arr);
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
//-XX:InitialHeapSize=268435456 -XX:MaxHeapSize=4294967296 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC 

JDK8使用的是PS PO。JDK9是G1

3.1 串行垃圾收集器Serial GC

串行垃圾收集器,是指使用单线程进行垃圾回收,垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停,等待垃圾回收的完成。这种现象称之为STW(Stop-The-World)

对于交互性强的应用而言,这种垃圾收集器是不能接受的。

一般在Javaweb应用中是不会采用该收集器的。

年轻代里用是的Serial,对应到年老代的较Serial Old

除了年轻代之外,Serial收集器还提供用于执行老年代垃圾收集的Serial Old收集器。 Serial Old收集器同样也采用了串行回收 和"Stop the World"机制。

  • 只不过内存回收算法使用的是标记–压缩算法。
  • ➢Serial Old是运行在Client模式下默认的老年代的垃圾回收器
  • ➢Serial Old在Server模式下主要有两个用途:①与新生代的ParallelScavenge配合使用; ②作为老年代CMS收集器的后备垃圾收集方案。

  • 简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Seria1收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
    • ➢运行在Client模式下的虛拟机是个不错的选择。
  • 在用户的桌面应用场景中,可用内存一般不大(几十MB至一两百MB), 可以在较短时间内完成垃圾收集(几十ms至一百多ms) ,只要不频繁发生,使用串行回收器是可以接受的。
  • 在HotSpot虛拟机中,使用-XX: +UseSerialGC 参数可以指定年轻代和老年代都使用串行收集器。
    • 等价于新生代用Serial GC,且老年代用Serial Old GC
    • 控制台输出 -XX:InitialHeapSize=268435456 -XX:MaxHeapSize=4294967296 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseSerialGC

3.1.1 编写测试代码

public class TestGC {
    //实现:不断产生新的数据(对象),随机的废弃对象(垃圾)
    public static void main(String[] args) throws Exception {
        List<Object> list = new ArrayList<>();
        while (true) {
            int sleep = new Random().nextInt(100);
            if (System.currentTimeMillis() % 2 == 0) {
                //当前的时间戳为偶数
                list.clear();//清空
            } else {
                //向list中添加10000个对象
                for (int i = 0; i<10000; i++) {
                    Properties properties = new Properties();
                    properties.put("key_" + i,"value_" + System.currentTimeMillis() + i);
                    list.add(properties);
                }
            }
            Thread.sleep(sleep);
        }
    }
}
3.1.2 设置垃圾回收为串行收集器

在程序运行参数中添加2个参数,如下:

  • -XX:+UseSerialGC:指定年轻代和老年代都使用串行垃圾收集器

  • -XX:+PrintGCDetails:打印垃圾回收的详细信息

# 为了测试GC,将堆的初始和最大内存都设置为16M
‐XX:+UseSerialGC ‐XX:+PrintGCDetails ‐Xms16m ‐Xmx16m

启动程序,可以看到下面信息:括号内为垃圾回收原因

[GC (Allocation Failure) [DefNew: 4416K->512K(4928K), 0.0034563 secs] 4416K->1841K(15872K), 0.0126067 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 因为分配内存失败而进行垃圾回收。DefNew代表是串行垃圾回收器

[Full GC (Allocation Failure) [Tenured: 10943K->10943K(10944K), 0.0205414 secs] 15871K->13831K(15872K), [Metaspace: 3311K->3311K(1056768K)], 0.0205747 secs] [Times: user=0.01 sys=0.00, real=0.02 secs]

GC日志信息解读:

年轻代的内存GC前后的大小:

  • DefNew

​ 表示使用的是串行垃圾收集器。

  • 4416K->512K(4928K)

​ 表示,年轻代GC前,占有4416K内存,GC后,占有512K内存,总大小4928K

  • 0.0034563 secs

​ 表示,GC所用的时间,单位为毫秒。

  • 4416K->1841K(15872K)

​ 表示,GC前,堆内存占有4416K,GC后,占有1841K,总大小为15872K

  • Full GC

​ 表示,内存空间全部进行GC

3.2 并行垃圾收集器

并行垃圾收集器在串行垃圾收集器的基础上做了改进,将单线程改为了多线程进行垃圾回收,这样可以缩短垃圾回收的时间

当然了,并行垃圾收集器在收集的过程中也会暂停应用程序,这个和串行垃圾收集器是一样的,只是并行执行,速度更快些,暂停的时间更短一些。

并行收集器与串行收集器工作模式相似,都是stop-the-world方式,只是暂停时并行地进行垃圾收集。年轻代采用复制算法,老年代采用标记-整理,在回收的同时还会对内存进行压缩。关注吞吐量主要指年轻代的Parallel Scavenge收集器,通过两个目标参数-XX:MaxGCPauseMills-XX:GCTimeRatio,调整新生代空间大小,来降低GC触发的频率。并行收集器适合对吞吐量要求远远高于延迟要求的场景,并且在满足最差延时的情况下,并行收集器将提供最佳的吞吐量。

3.2.1 ParNew垃圾收集器(年轻代)+CMS(年老代)

ParNew(Parallel New)垃圾收集器是工作在年轻代上的,只能处理的是新生代,只是将串行的垃圾收集器改为了并行。parallel Scanvenge的增强,为了匹配年老代的CMS

通过-XX:+UseParNewGC参数设置年轻代使用ParNew回收器,老年代使用的依然是串行收集器。

  • 如果说Serial GC是年轻代中的单线程垃圾收集器,那么ParNew收集器则是Serial收集器的多线程版本。
  • ParNew收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。ParNew收集器在年轻代中同样也是采用复制算法、"Stop-the-World"机制。
  • ParNew是很多JVM运行在Server模式下新生代的默认垃圾收集器。
  • PN可以和Serial Old及CMS配合工作

  • 由于ParNew收集器是基于并行回收,那么是否可以断定ParNew收集器的回收效率在任何场景下都会比Serial收集器更高效?
    • ➢ParNew 收集器运行在多CPU的环境下,由于可以充分利用多CPU、 多核心等物理硬件资源优势,可以更快速地完成垃圾收集,提升程序的吞吐量。
    • ➢但是在单个CPU的环境下,ParNew收 集器不比Serial收集器更高效。虽然Serial收集器是基于串行回收,但是由于CPU不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。
  • 在程序中,开发人员可以通过选项"-XX:+UseParNewGC"手动指定使用ParNew收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。
  • -XX:ParallelGCThreads 限制线程数量,默认开启和CPU数据相同的线程数。.

对于新生代,回收次数频繁,使用并行方式高效。

对于老年代,回收次数少,使用串行方式节省资源。(CPU并行 需要切换线程,串行可以省去切换线程的资源)

测试:

参数
‐XX:+UseParNewGC ‐XX:+PrintGCDetails ‐Xms16m ‐Xmx16m

#打印出的信息

[GC (Allocation Failure) [ParNew: 4416K->512K(4928K), 0.0026548 secs] 4416K->1863K(15872K), 0.0026831 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

由以上信息可以看出,ParNew: 使用的是ParNew收集器。其他信息和串行收集器一致。

3.2.2 PS+PO垃圾收集器

ParallelGC即Parallel Scanvenge+Parallel Old。JDK8的默认回收器

PS是吞吐量优先收集器

Parallel GC收集器工作机制和ParNew GC收集器一样,只是在此基础上,新增了两个和系统吞吐量相关的参数,使得其使用起来更加的灵活和高效。相当于原来是一个人打扫,现在是多个人打扫快速打扫完

高吞吐量则可以高效率地利用CPU的时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务,不要求低延迟。因此,常见在服务器环境中使用 内存回收性能很不错。例如,那么执行批量处理、订单处理、工资支付、科学计算的应用程序。

HotSpot的年轻代中除了拥有ParNew收集器是基于并行回收的以外, Parallel Scavenge收集器同样也采用了复制算法、并行回收和"Stop the World"机制。那么Parallel收集器的出现是否多此一举?

  • ➢和ParNew收集器不同,Parallel Scavenge收集器的目标则是达到一个==可控制的吞吐量(Throughput)==,它也被称为吞吐量优先的垃圾收集器
    • 吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
    • 高吞吐量则可以高效率地利用CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。
  • 自适应调节策略也是Parallel Scavenge 与ParNew一个重要区别。自适应:根据情况调整内存分配情况。

新生代调小一点,GC时间就短了,吞吐量就降低了

PO

  • Parallel收集器在JDK1.6时提供了用于执行老年代垃圾收集的 Parallel Old收集器,用来代替老年代的Serial Old收集器。

  • Parallel Old收集器采用了标记-压缩算法,但同样也是基于并行回收和”Stop-the-World"机制。

  • 在程序吞吐量优先的应用场景中,PS PO的组合,在Server模式下的内存回收性能很不错。

  • 在Java8中,默认是PO垃圾收集器

相关参数如下:

  • -XX:+UseParallelGC: 年轻代使用PS垃圾收集器,老年代使用Serial串行回收器。

  • -XX:+UseParallelOldGC: 年轻代使用PS垃圾回收器,老年代使用PO垃圾回收器。

  • -XX:MaxGCPauseMillis:设置垃圾收集时的最大停顿时间,单位为毫秒

​ 需要注意的是,ParallelGC为了达到设置的停顿时间,可能会调整堆大小或其他的参数,如果堆的大小设置的较小,就会导致GC工作变得很频繁,反而可能会影响到性能。比如堆满了我们得GC了,但是GC设置是时间比较短,还没清完垃圾又到时得去工作了,还没new几个对象又满了,又得去GC,GC又释放不了几个又去工作了。。。恶性循环

​ 该参数使用需谨慎。对于客户来讲是低延迟好。但我们服务器端注重高并发,整体的吞吐量,所以服务器端使用PS+PO

  • -XX:GCTimeRatio
    • 设置垃圾回收时间占程序运行时间的百分比,公式为1/(1+n),控制吞吐量大小
    • 它的值为1~100之间的数字,默认值为99,也就是垃圾回收时间不能超过1%。一般不设置
  • -XX:UseAdaptiveSizePolicyGC自适应模式,垃圾回收器将自动调整年轻代、老年代等参数,达到吞吐量、堆大小、停顿时间之间的平衡。一般用于,手动调整参数比较困难的场景,让收集器自动进行调整。

#参数

‐XX:+UseParallelGC ‐XX:+UseParallelOldGC ‐XX:MaxGCPauseMillis=100 ‐XX:+PrintGCDetails ‐Xms16m ‐Xmx16m

#打印的信息

[GC (Allocation Failure) [PSYoungGen: 4096K->480K(4608K)] 4096K->1700K(15872K), 0.0108734 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

[Full GC (Ergonomics) [PSYoungGen: 498K->0K(2560K)] [ParOldGen: 8492K->1889K(11264K)] 8991K->1889K(13824K), [Metaspace: 3306K->3306K(1056768K)], 0.0182486 secs] [Times: user=0.02 sys=0.00, real=0.02 secs]

由以上信息可以看出,年轻代和老年代都使用了ParalledGC垃圾回收器。

CPU数量大于8个的时候,回收线程个数=3+(5×CPU)/8

将CMS之前先明确下并发并行的概念:

我们经常提高并发,并发是指多个cpu同时运行,在gc中即运行代码cpu与垃圾回收cpu同时进行,

并发是依次发生。我们只记高并发即可,高并发是多cpu。

3.3 CMS垃圾收集器(老年代)+PN(年轻代)+Serial Old

并发标记清除收集器:并发标记清除收集器组合 ParNew + CMS + Serial Old

CMS用于老年代垃圾回收,PN用于年轻代垃圾回收

但是:CMS 收集器是唯一不进行压缩的收集器,在它释放了垃圾对象占用的空间后,它不会移动存活对象到一边去。

所以碎片多了就需要使用 Serial Old进行老年代内存压缩

CMS全称Concurrent Mark Sweep(并发标记清除,工作线程和垃圾回收线程同时执行),是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,通过参数-XX:+UseConcMarkSweepGC进行设置。是为了解决停顿的问题,所以他的优点是垃圾回收的时候程序可以继续执行,因为我们只回收没用的位置,有用的位置不改变,程序还能找到。

CMS 是HotSpot虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。(JMD1.5开始)

CMS收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间。停顿时间越短(低延迟)就越适合与用户交互的程序,良好的响应速度能提升用户体验。希望系统停顿时间最短,以给用户带来较好的体验,CMS收集器就非常符合这类应用的需求。

当年老代达到特定的占用比例时,CMS开始执行。

与之对应的年轻代垃圾回收器是PN,PN是PS为了匹配CMS升级的

PN在jdk9被移除了,CMS在JDK10被移除

  • CMS的垃圾 收集算法采用标记-清除算法,并且也会"stop the world"
  • 不幸的是,CMS 作为老年代的收集器,却无法与JDK 1.4.0 中已经存在的新生代收集器Parallel Scavenge配合工作,所以在JDK 1. 5中使用CMS来收集老年代的时候,新生代只能选择ParNew或者Serial收集器中的一个。
  • 在G1出现之前,CMS使用还是非常广泛的。一直到今天,仍然有很多系统使用CMS GC。

CMS因为不进行压缩:

  • 这将节省垃圾回收的时间,但是由于之后空闲空间不是连续的,所以也就不能使用简单的 指针碰撞(bump-the-pointer) 进行对象空间分配了。它需要维护一个 空闲列表,将所有的空闲区域连接起来,当分配空间时,需要寻找到一个可以容纳该对象的区域。显然,它比使用简单的指针碰撞成本要高。同时它也会加大年轻代垃圾收集的负载,因为年轻代中的对象如果要晋升到老年代中,需要老年代进行空间分配。
  • 另外一个缺点就是,CMS 收集器相比其他收集器需要使用更大的堆内存。因为在并发标记阶段,程序还需要执行,所以需要留足够的空间给应用程序。另外,虽然收集器能保证在标记阶段识别出所有的存活对象,但是由于应用程序并发运行,所以刚刚标记的存活对象很可能立马成为垃圾,而且这部分由于已经被标记为存活对象,所以只能到下次老年代收集才会被清理,这部分垃圾称为 浮动垃圾
  • 最后,由于缺少压缩环节,堆将会出现碎片化问题。为了解决这个问题,CMS 收集器需要追踪统计最常用的对象大小,评估将来的分配需求,可能还需要分割或合并空闲区域。
  • 不像其他垃圾收集器,CMS 收集器不能等到老年代满了才开始收集。否则的话,CMS 收集器将退化到使用更加耗时的 stop-the-world、标记-清除-压缩 算法。为了避免这个,CMS 收集器需要统计之前每次垃圾收集的时间和老年代空间被消耗的速度。另外,如果老年代空间被消耗了 预设占用率(initiating occupancy),也将会触发一次垃圾收集,这个占用率通过 –XX:CMSInitiatingOccupancyFraction=n 进行设置,n 为老年代空间的占用百分比,默认值是 68(java8中是92)。
  • 如果老年代空间不足以容纳从新生代垃圾回收晋升上来的对象,那么就会发生 concurrent mode failure,此时会退化到发生 Full GC,清除老年代中的所有无效对象,这个过程是单线程的,比较耗时

CMS垃圾回收器的执行过程如下:初始标记、并发标记、重新标记、并发清理

主要有初始标记,并发标记,重新标记,并发清理。刚要扔的时候又有别的指向过来了,又不能扔了。

其实不只这四个阶段,中间还有一些其他操作,如预清理、concurrent Abortable Preclean

三色标记

三色标记:是一种逻辑上的抽象。将每个内存对象分成三种颜色:

  • 黑色:表示自己和成员变量(就是自己引用的对象)都已经标记完毕。
  • 灰色:自己标记完了,但是成员变量还没有标记完
  • 白色:自己未标记完

错标记和漏标记

A原来的成员变量指向B,现在指向C了,C没有标记到,C漏标记。漏标记就根不可达了。CMS采用增量标记解决漏标问题,黑色的框重新标记,变为灰色

别的算法中有采样快照SATB的,比如原来被引用,现在不被引用了,就加入到一个表中,然后看C的RSet有没有被别人引用,决定GC与否

对象消息问题:扫描过程中插入了一条或多条从黑色对象到白色对象的新引用,并且同时去掉了灰色对象到该白色对象的直接引用或间接引用

增量更新(CMS采用)、原始快照ASTB snapshot at the beginnng

①初始化标记

初始化标记(CMS-initial-mark):标记根对象root,会导致stw,但因为只标记根对象和从年轻代顺过来的对象,所以stw很短。一旦标记完成之后就会恢复之前被暂停的所有应用线程。

  • 标记老年代中所有的GC Roots对象,如下图节点1;
  • 标记年轻代中活着的对象引用到的老年代的对象(指的是年轻带中还存活的引用类型对象,引用指向老年代中的对象)如下图节点2、3;

根对象

在Java语言里,可作为GC Roots对象的包括如下几种:

  • 虚拟机栈(栈桢中的本地变量表)中的引用的对象;
  • 方法区(元空间)中的类静态属性引用的对象;
  • 方法区中的常量引用的对象;
  • 本地方法栈中JNI的引用的对象;

ps:为了加快此阶段处理速度,减少停顿时间,可以开启初始标记并行化,-XX:+CMSParallelInitialMarkEnabled,同时调大并行标记的线程数,线程数不要超过cpu的核数。

②并发标记
  • 并发标记(CMS-concurrent-mark):与用户线程同时运行;捋着一部分根对象,进行一部分标记。从GC Roots的 直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  • 遍历InitialMarking阶段标记出来的存活对象,然后继续递归标记这些对象可达的对象。
    • 因为该阶段并发执行的,在运行期间可能发生新生代的对象晋升到老年代、或者是直接在老年代分配对象、或者更新老年代对象的引用关系等等,对于这些对象,都是需要进行重新标记的,否则有些对象就会被遗漏,发生漏标的情况。(总结为新到老年代的)
    • 为了提高重新标记的效率,该阶段会把上述对象所在的Card标识为Dirty,后续(重新标记)只需扫描这些Dirty Card的对象,避免扫描整个老年代。(注:始终不会遍历整个老年代,只遍历其中的dirty。而且刚开始的时候都是从gc root和年轻代顺过来的。在并发标记过程中可能有年轻代晋升到老年代的情况,我们就直接标记为dirty,这样我们就能顺着我们已经知道的全找到老年代对象,而不是遍历老年代所有)
    • 比如下面图中黑色的代表并发标记时接着捋上个阶段标记的,还有直接根对象直接引用的,新晋升的标记为脏的绿色
    • 并发标记时并不是老年代所有存活对象都会被标记,因为在标记期间用户的程序可能会改变一些引用。比如3那么那个节点断开了

预清理:

  • 预清理(CMS-concurrent-preclean):预用户线程同时运行;前一个阶段已经说明,不能标记出老年代全部的存活对象,是因为标记的同时应用程序会改变一些对象引用,这个阶段就是用来处理前一个阶段因为引用关系改变导致没有标记到的存活对象的,它会扫描所有标记为Dirty的Card

    • 如下图所示,在并发清理阶段,节点3的引用指向了6;则会把节点3的card标记为Dirty
  • 最后将6标记为存活,如下图所示:在预清理阶段,那些从dirty对象可达的对象也会被标记,这个标记做完之后,dirty card标记就会被清除了

Concurrent Abortable Preclean:

这也是一个并发阶段,但是同样不会影响用户的应用线程

这个阶段是为了尽量承担STW中最终标记阶段的工作。这个阶段持续时间依赖于很多的因素,由于这个阶段是在重复做很多相同的工作,直接满足一些条件(比如:重复迭代的次数、完成的工作量或者时钟时间等)

③重新标记

想象一下下面这个情形:

A对象已经处理过, 但是B对象正在处理中。在并发标记阶段,与此同时用户线程正在执行,

现在用户标记完与A相关的对象了,而B对象原来引用的C现在不引用C了,但A(的属性)又引用到了C。

但是与A相关的对象已经标记过了,不会再标记了,系统就会认为C没有被其他对象引用,会被垃圾回收。

为了避免这种情况,就需要暂停所有的用户线程,重新扫描一遍全部对象,这样就能扫描到C被A引用了。

因为这个阶段中大多数对象已经在并发标记阶段标记过了,所以只需重新标记像C这种对象,所以stw很短。

此外,当C被其他对象引用时,JVM就会给C加入写屏障,写屏障的代码就会被执行,C就会被加入到队列中,把C变成灰色,即还没标记完的对象。并发标记结束后,重新标记时就会从队列中取出对象进行检查,发现是灰色的话,进一步处理标记

重新标记(CMS-remark):会导致stw,重新标记的内存范围是整个堆,包含_young_gen_old_gen,只标记上个阶段标记错误的,也很快;(最终标记,为什么要标记两次呢?因为前面并发标记的时候也有程序正在运行,应用程序也在不断地申请内存空间,有可能会有新对象,也可能会有垃圾,所以需要二次标记)。这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。

  • 这个阶段会导致第二次stop the word,该阶段的任务是完成标记整个年老代的所有的存活对象。由于之前的阶段是并发执行的,gc线程可能跟不上应用程序的变化,为了完成标记老年代所有存活对象的目标,STW就非常有必要了。

    通过CMS的重新标记阶段会在年轻代尽可能感觉的时候运行,目的是为了减少连续STW发生的可能性(年轻代存活对象过多的话,也会导致老年代涉及的存活对象会很多)。

这个阶段,为什么要扫描新生代呢,因为对于老年代中的对象,如果被新生代中的对象引用,那么就会被视为存活对象,即使新生代的对象已经不可达了,也会使用这些不可达的对象当做CMS的“gc root”,来扫描老年代; 因此对于老年代来说,引用了老年代中对象的新生代的对象,也会被老年代视作“GC ROOTS”:当此阶段耗时较长的时候,可以加入参数-XX:+CMSScavengeBeforeRemark在重新标记之前,先执行一次young gc,回收掉年轻带的对象无用的对象,并将对象放入幸存区或晋升到老年代,这样再进行年轻带扫描时,只需要扫描幸存区的对象即可,一般幸存区非常小,这大大减少了扫描时间。
由于之前的预处理阶段是与用户线程并发执行的,这时候可能年轻代的对象对老年代的引用已经发生了很多改变,这个时候,remark阶段要花很多时间处理这些改变,会导致很长stop the word,所以通常CMS尽量运行Final Remark阶段在年轻代是足够干净的时候。
另外,还可以开启并行收集:-XX:+CMSParallelRemarkEnabled。

  • 并发标记阶段还可能产生其他新的引用关系如下:

    • 老年代的新对象被GC Roots引用
    • 老年代的未标记对象被新生代对象引用
    • 老年代已标记的对象增加新引用指向老年代其它对象
    • 新生代对象指向老年代引用被删除
    • 也许还有其它情况…
  • 上述对象中可能有一些已经在Precleaning阶段和AbortablePreclean阶段被处理过,但总存在没来得及处理的,所以还有进行如下的处理:

    • 遍历新生代对象,重新标记
    • 根据GC Roots,重新标记
    • 遍历老年代的Dirty Card,重新标记,这里的Dirty Card大部分已经在clean阶段处理过

经历过上面5个阶段之后,老年代所有存活对象都被标记过了,现在可能通过清除算法去清理那么老年代不再使用的对象。

④并发清除
  • 并发清除(CMS-concurrent-sweep):与用户线程同时运行;此阶段清理删除掉标记阶段判断为已经死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
  • 调整堆大小:设置CMS在清理之后进行内存压缩,目的是清理内存中的碎片;
  • 并发重置状态等待下次CMS的触发(CMS-concurrent-reset),与用户线程同时运行;

清理过后老年代只剩下123456

CMS缺点

CMS 收集器是唯一不进行压缩的收集器,在它释放了垃圾对象占用的空间后,它不会移动存活对象到一边去。

他的缺点是当碎片特别多的时候会采取极端的方式用Serial Old把年老代清理一遍(CMS运行期间预留的内存无法满足程序需要,就出现Cocurrent Mode Failure,启动Serial Old)。所以任何一个jdk默认的垃圾回收器都不是CMS。

  • CMS收集器对CPU资源非常敏感
  • 由于并发进行,CMS在收集与应用线程会同时会增加对堆内存的占用,也就是说,CMS必须要在老年代堆内存用尽之前完成垃圾回收,否则CMS回收失败时,将触发担保机制,串行老年代收集器将会以STW的方式进行一次GC,从而造成较大停顿时间;
  • 标记清除算法无法整理空间碎片,老年代空间会随着应用时长被逐步耗尽,最后将不得不通过担保机制对堆内存进行压缩。CMS也提供了参数-XX:CMSFullGCsBeForeCompaction(默认0,即每次都进行内存整理)来指定多少次CMS收集之后,进行一次压缩的Full GC。
  • CMS收集器无法处理浮动垃圾(Floating Garbage),可能出现“Concurrnet Mode Failure"失败而导致另一次 Full GC的产生。如果在应用中老年代增长不是太快,可以适当调高参数-XX:CMSInitiating OccupancyFrac的值来提高触发百分比,以便降低内存回收次数从而获取更好的性能。要是CMS运行期间预留的内存无法满足程序需要时,虚拟机将启动后备预案:临时启用Seriat Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置得太高很容易导致大量“Concurrent Mode Failure”失败,性能反而降低。
  • 收集结束时会有大量空间碎片产生,空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代还有很大空间剩余但是无法找到足够大的连续空间来分配当前对象,不得不提前进行一次Full GC.M收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶 Full不住要进行 GC时开启内存碎片的合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间不得不变长。

并发标记清除(CMS)是以关注低延迟为目标、十分优秀的垃圾回收算法,开启后,年轻代使用STW式的并行收集,老年代回收采用CMS进行垃圾回收,对延迟的关注也主要体现在老年代CMS上。

年轻代ParNew与并行收集器类似,而老年代CMS每个收集周期都要经历:初始标记、并发标记、重新标记、并发清除。其中,初始标记以STW的方式标记所有的根对象;并发标记则同应用线程一起并行,标记出根对象的可达路径;在进行垃圾回收前,CMS再以一个STW进行重新标记,标记那些由mutator线程(指引起数据变化的线程,即应用线程)修改而可能错过的可达对象;最后得到的不可达对象将在并发清除阶段进行回收。值得注意的是,初始标记和重新标记都已优化为多线程执行。CMS非常适合堆内存大、CPU核数多的服务器端应用,也是G1出现之前大型应用的首选收集器。

年轻代为什么不用cms:年轻代复制算法更好

1.由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。

2.尽管CMS收集器采用的是并发回收(非独占式),但是在其初始化标记和重新标记这两个阶段中仍然需要执行“Stop一the一World”机制暂停程序中的工作线程,不过暂停时间并不会太长。

3.因此可以说明目前所有的垃圾收集器都做不到完全不需要“Stop-the-World”,只是尽可能地缩短暂停时间。

4.另外,由于在垃圾收集阶段用户线程没有中断,所以在CMS回收过程中,还应该确保应用程序用户线程有足够的内存可用。因此,CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是当堆内存使用率达到某一阈值时,便开始进行回收,以确保应用程序在CMS工作过程中依然有足够的空间支持应用程序运行。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial 0ld收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

5.CMS收集器的垃圾收集算法采用的是标记-清除算法,这意味着每次执行完内存回收后,由于被执行内存回收的无用对象所占用的内存空间极有可能是不连续的一些内存块,不可避免地将会产生一些内存碎片。

那么CMS在为新对象分配内存空间时,将无法使用指针碰撞(Bump the Pointer) 技术,而只能够选择空闲列表(Free List) 执行内存分配。

有人会觉得既然标记-清除会造成内存碎片,那么为什么不把算法换成标记-压缩呢?
答案其实很简答,因为当并发清除的时候,用Compact整理内存的话,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。标记-压缩更适合“Stop the World”这种场景”下使用。

  • 1)会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发Full GC。
  • 2) CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
  • 3) CMS收集器无法处理浮动垃圾。可能出现“Concurrent Mode Failure" 失败而导致另一次Full GC的产生。
    • 浮动垃圾:在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交叉运行的,那么在并发标记阶段如果产生新的垃圾对象,CMS将 无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行GC时释放这些之前未被回收的内存空间。
  • CMS GC线程建议设置为总CPU数的1/4

-XX:CMSInitiatingOccupantFraction默认92%时开始CMS GC

3.3.1 cms测试

#设置启动参数
‐XX:+UseConcMarkSweepGC ‐XX:+PrintGCDetails ‐Xms16m ‐Xmx16m

#运行日志

[GC (Allocation Failure) [ParNew: 4416K->512K(4928K), 0.0074759 secs] 4416K->1859K(15872K), 0.0075204 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
#第一步,初始标记

[GC (CMS Initial Mark) [1 CMS-initial-mark: 6160K(10944K)] 6759K(15872K), 0.0004109 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
#第二步,并发标记

[CMS-concurrent-mark-start]
[CMS-concurrent-mark: 0.003/0.003 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

#第三步,预处理

[CMS-concurrent-preclean-start]
[CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

#第四步,重新标记

[GC (CMS Final Remark) [YG occupancy: 687 K (4928 K)][Rescan (parallel) , 0.0001925 secs][weak refs processing, 0.0000504 secs][class unloading, 0.0002354 secs][scrub symbol table, 0.0004174 secs][scrub string table, 0.0001073 secs][1 CMS-remark: 6160K(10944K)] 6847K(15872K), 0.0010680 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
#第五步,并发清理

[CMS-concurrent-sweep-start]
[CMS-concurrent-sweep: 0.003/0.003 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

#第六步,重置
[CMS-concurrent-reset-start]
[CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

由以上日志信息,可以看出CMS执行的过程。

场景:订单。虽然有垃圾,但是最后1s提交的还不是垃圾,此时并不是放入s0,而是因为大对象直接放入了老年代。然后老年代太满了就触发full gc,stw时间更长。此时把survivor区调大即可

3.4 G1垃圾收集器

官方给G1设定的目标是:在延迟可控的情况下获得尽可能高的吞吐量。“全功能收集器”

G1(Garbage First)。G1最大的特点是引入分区的思路,弱化了分代的概念,合理利用垃圾收集各个周期的资源,解决了其他收集器甚至CMS的众多缺陷。

  • JDK6U14体验
  • jdk1.7 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)。JDK7U4官方支持G1
  • jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)简称PS PO。分布式锁续期时是不建议的,用G1。-XX:+UseG1GC
  • jdk1.9 默认垃圾收集器G1

适用场景:

  • 同时注重吞吐量和低延迟,默认暂停目标为200ms
  • 超大堆内存,会将堆划分为多个大小相等的region
  • 整体上是标记+整理算法,两个区域之间是复制算法。两种算法都可以避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。尤其是当Java堆非常大的时候,G1的优势更加明显。
  • 可预测的停顿时间模型(即:软实时soft real-time)
    这是G1相对于CMS的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上不得超过N毫秒。
    • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
    • G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小及回收所需时间的经验值),在后台维护了一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。
    • 相比于CMS GC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多。

-XX:+UseG1GC

-XX:+PrintCommandLineFlagsjvm参数可查看默认设置收集器类型

-XX:+PrintGCDetails亦可通过打印的GC日志的新生代、老年代名称判断

  • -XX:G1HeapRegionSize=size
  • -XX:MaxGCPauseMillis=200 指定期望的停顿时间。

G1的设计原则就是简化JVM性能调优,开发人员只需要简单的三部即可完成调优:

  1. 第一步,开启G1垃圾收集器
  2. 第二步,设置堆的最大内存
  3. 第三步,设置最大的停顿时间

G1中提供了三种模式垃圾回收模式,Young GC、Mixed GC和Full GC,在不同的条件下被触发。

3.4.1 原理

https://www.jianshu/p/aef0f4765098

G1垃圾收集器相对比其他收集器而言,最大的区别在于它取消了年轻代、老年代的物理划分,取而代之的是将堆划分为若干个区域(Region),这些区域中包含了又逻辑上的年轻代、老年代区域。

1)region

G1采用了分区(Region)的思路,将整个堆空间分成若干个大小相等的内存区域,每个region可以是年轻代、老年代的一个,每次分配对象空间将逐段地使用内存。因此,在堆的使用上,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可;每个分区也不会确定地为某个代服务,可以按需在年轻代和老年代之间切换。启动时可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区

这样做的好处就是,我们再也不用单独的空间对每个代进行设置了,不用担心每个代的内存是否足够

Region可以说是G1回收器一次回收的最小单元。即每一次回收都是回收N个Region。这个N是多少,主要受到G1回收的效率和用户设置的软实时目标有关。每一次的回收,G1会选择可能回收最多垃圾的Region进行回收。与此同时,G1回收器会维护一个空闲Region的链表。每次回收之后的Region都会被加入到这个链表中。

  • 比如将Eden小块拷贝到了某个Survivor小块,此时原来Eden区就可以放其他内容了。垃圾回收+内存压缩。
  • E
  • S
  • O
  • H:Humongous区域(巨型对象),如果一个对象占用的空间超过了分区容量的50%以上,G1收集器就认为这是一个巨型对象。这些巨型对象,默认直接会被分配在老年代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。
  1. G1收集器,默认将Java堆划分成约2048个大小相同的独立的Region块,每一个Region块大小根据堆空间的时间大小来决定,范围控制在1MB32MB之内。所有的Region大小相同,且在JVM生命周期内不会改变(除非JVM停止之后,重新设置Region的大小,否则region的大小不会改变)。
    1. 堆分为2048个region,每个region又每512B分为卡片
  2. Region使新生代和老年代的物理空间可以是不连续的。
  3. 如下图,堆区被划分成了各个Region,各个Region分别表示Eden区,Survivor区,Old区。一个Region只能属于一个角色,也就是说一个Region不能一部分是Eden区,一部分是Old区或Survivor区。
  4. G1垃圾收集器还新增加了一种内存区域Humongous(H)区。主要用于存储大对象,如果对象大小超过1.5Region,就放到H
  5. 设置H区的原因:对于堆中的大对象,默认会被分配到老年代,但是如果它是一个短期存在的对象,由于老年代垃圾收集的频率较低,这个对象是不能及时被回收掉的,会对垃圾收集造成负面的影响。
    设置H区,就能够及时回收。如果一个H区装不下一个大对象,则寻找连续的H区来存储,如果找不到连续的H区,就会启动Full GC。G1的大多数行为都把H区作为老年代的一部分来看待。
2)卡片

卡片:在每个region又被分成了若干个大小为512 Byte卡片(Card),卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象(见RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。

3)Rset

问题引入:G1将堆区划分成多个region,每个region不可能是独立的,它其中存储的对象可能被其他任意region(这些region可能Old区或者Eden区)中的对象所引用。这样一来,在进行YGC的时候,判断Eden区中的一个对象是否存活时,需要去扫描所有的region(包括Old区,Eden区等),导致了在回收年轻代的时候,还需要扫描老年代,同时扫描表示所有Eden区和Old区的region,相当于做了一个全堆扫描,这会大大降低YGC的效率。

在GC年轻代的对象时,我们如何找到年轻代中对象的根对象呢?

根对象可能是在年轻代中,也可能在老年代中,那么老年代中的所有对象都是根对象吗?

如果全量扫描老年代,那么这样扫描下来会耗费大量的时间。

于是,G1引进了RSet的概念。它的全称是Remembered Set,每个Region初始化时,会初始化一个RSet,该集合用来记录并跟踪谁引用了我,这样就知道我里的某个对象应不应该被回收。a.b=b; c.b=b;

RRSET指明老年代哪块的对象引用了年轻代对象,只需要扫描RSet即可。为什么不指定具体的老年代对象呢?对象有存储和维护成本。所以minorGC时就根据卡表把老年代指定块(卡片)中的对象全部进行计算,这样就避免了整个老年大的扫描。如果引用发生了变化需要维护开销,但是比扫描整个老年代开销小。

什么时候记录RSet:每次在对一个对象引用进行赋值的时候,会产生一个写屏障中断操作,然后检查将要写入的引用指向的对象是否和该引用当前指向的对象处在不同的region中;如果region不同,通过CardTable将相关的引用信息记录到Remembered set中;当进行垃圾收集时,在GC根节点的枚举范围(查找范围)内加入Remembered Set,就可以保证不用进行全局扫描。

如区块 A 中的对象引用了区块 B,区块 B 的 Rset 需要记录这个信息

总体上 Remembered Sets 消耗的内存小于 5%。其实是一个hashMap,key是别的region的起始地址,value是一个集合,里面的元素是card table的index.

4)写屏障

赋值时需要维护卡表时,通过写屏障技术

在RS的修改上也会遇到并发的问题。因为一个Region可能有多个线程在并发修改,因此它们也会并发修改RS。为了避免这样一种冲突,G1垃圾回收器进一步把RS划分成了多个哈希表。每一个线程都在各自的哈希表里面修改。最终,从逻辑上来说,RS就是这些哈希表的集合。哈希表是实现RS的一种通常的方式之一。它有一个极大的好处就是能够去除重复。这意味着,RS的大小将和修改的指针数量相当。而在不去重的情况下,RS的数量和写操作的数量相当。

写屏障与记忆集:
什么时候记录RSet:每次在对一个对象引用进行赋值的时候,会产生一个写屏障中断操作,然后检查将要写入的引用指向的对象是否和该引用当前指向的对象是否处在不同的region;如果region不同,通过CardTable将相关的引用信息记录到Remembered set中;当进行垃圾收集时,在GC根节点的枚举范围(查找范围)内加入Remembered Set,就可以保证不用进行全局扫描。

总结:跨代引用。新生代的根对象有一部分来自老年代,这时如果遍历老年代很耗时,所以使用卡表。如果老年代对象引用了新生代对象,就把老年代card table这块标记为dirty card,同时把该老年代记录在年轻代的RSet中。这样就不要找整个老年代了,减少搜索范围。下面粉色的是脏卡区。

Snapshot-At-The-Beginning(SATB)是 GC在并发标记阶段使用的增量式的标记算法

如果card改变了,比如从null赋值成值了,就在card table里标记为dirty,这样Rset就可以指向卡表里对应的一个entry。

在引用变更时通过 post-write barrier写屏障 + dirty card queue
concurrent refinement threads 更新 Remembered Set

https://blog.csdn/shlgyzl/article/details/95041113

5)TLABs和指针碰撞

每一个分配的Region分区,都可以分成两个部分,已分配的和未被分配的。它们之间的界限被称为top。总体上来说,把一个对象分配到Region内,只需要简单增加top的值。这个做法实际上就是bump-the-pointer(指针碰撞,上面讲过)。过程如下:

每一次都只有一个Region处于被分配的状态中,被称为current region。在多线程的情况下,这会带来并发的问题。G1回收器采用和CMS一样的TLABs的手段。即为每一个线程分配一个Buffer,线程分配内存就在这个Buffer内分配。但是当线程耗尽了自己的Buffer之后,需要申请新的Buffer。这个时候依然会带来并发的问题。G1回收器采用的是CAS申请新Buffer

为线程分配Buffer的CAS过程大概是:

  • 记录top值;
  • 准备分配;
  • 比较记录的top值和现在的top值,如果一样,则执行分配,并且更新top的值;否则,重复1;

显然的,采用TLABs的技术,就会带来碎片。举例来说,当一个线程在自己的Buffer里面分配的时候,虽然Buffer里面还有剩余的空间,但是却因为分配的对象过大以至于这些空闲空间无法容纳,此时线程只能去申请新的Buffer,而原来的Buffer中的空闲空间就被浪费了。Buffer的大小和线程数量都会影响这些碎片的多寡。

G1的设计目标

  • 与应用线程同时关注,几乎不需要STW(与CMS类似)
  • 整理剩余空间,不产生内存碎片(CMS只能在Full GC时,用STW整理内存碎片)
  • GC停顿更加可控
  • 不牺牲系统的吞吐量
  • gc不需要额外的内存空间(CMS需要预留空间存储浮动垃圾)
6)回收性价比集合

策略:

年轻代GC:STW,在E和S中使用复制算法,也即完成了堆的压缩,即不会产生内存碎片

收集集合(回收性价比):G1 GC有计划地避免在整个java堆中进行全区域的垃圾收集。G1收集各个region里面的垃圾堆积的价值大小(回收锁获得的空间大小自己回收所需使劲的经验值),后后台维护一下优先列表,每次根据允许的手机时间,优先回收价值最大的region。无需回收整个堆,而是选择一个Collection Set (CS)

由于这种方式的侧重点在于回收最大量的空间,所以G1叫垃圾优先

吞吐量:吞吐量关注的是,在一个指定的时间内,最大化一个应用的工作量。

对于关注吞吐量的系统,卡顿是可以接受的,因为这个系统关注长时间的大量任务的执行能力,单词快速的响应并不值得考虑

两种GC:

  • Fully young GC
  • Mixed GC

我们想要知道这个region外的哪些对象引用了要回收的region的。有下面两种机制

https://blog.csdn/u011069294/article/details/108370587

G1回收过程

  • 1、年轻代收集Young GC
  • 2、并发收集,和应用线程同时执行
    • 并发标记过程:当堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。
  • 3、混合式垃圾收集
    • 标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的.部分。和年轻代不同,老年代的G1回收器和其他GC不同,GI的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的region就可以了。同时,这个老年代region是和年轻代一起被回收的。
  • *、必要时的 Full GC
    • mixed GC失败

应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到su“or区间或者老年区间,也有可能是两个区间都会涉及。

举个例子:一个web服务器,java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后始四到五次的混合回收

  1. 首先,最好不要把上面的 Old GC 当做是一次 GC 来看,而应该当做并发标记周期来理解,虽然它确实会释放出一些内存。

  2. 并发标记结束后,G1 也就知道了哪些区块是最适合被回收的,那些完全空闲的区块会在这这个阶段被回收。如果这个阶段释放了足够的内存出来,其实也就可以认为结束了一次 GC。

  3. 我们假设并发标记结束了,那么下次 GC 的时候,还是会先回收年轻代,如果从年轻代中得到了足够的内存,那么结束;过了几次后,年轻代垃圾收集不能满足需要了,那么就需要利用之前并发标记的结果,选择一些活跃度最低的老年代区块进行回收。直到最后,老年代会进入下一个并发周期。

那么什么时候会启动并发标记周期呢?这个是通过参数控制的,下面马上要介绍这个参数了,此参数默认值是 45,也就是说当堆空间使用了 45% 后,G1 就会进入并发标记周期。

1) Young GC
  • 触发时机:Eden空间满时会被触发
  • 针对区域:Young GC只会回收Eden区和Survivor区
  • 初始标记
  • Eden空间的数据移动到Survivor空间中,如果Survivor空间不够,Eden空间的部分数据会直接晋升到年老代空间。
  • Survivor区的数据移动到新的Survivor区中,也有部分数据晋升到老年代空间中。
  • 最终Eden空间的数据为空,GC停止工作,应用线程继续执行。
  • E到S有STW,时间短

YGC步骤:

  • 停止应用程序执行,stw,G1创建回收集合Collection Set,回收集合是指需要被回收的region的集合,年轻代回收过程的回收集包含年轻代Eden和Survivor区所有的region
  • Eden区中存活的对象以及from区(Survivor区)中存活的对象,被移动到了to区。
  • 如图所示,箭头的指向为回收完要挪到的地方

YGC详细过程:

  • 处理卡表中的脏数据,然后也因此处理了RSet,然后转移对象
2) ConMark
  • 在 Young GC 时会进行 GC Root 的初始标记
    • 初始标记是找到根对象,并发标记是顺着根对象找到其他对象。初始标记是新生代GC时发生,并发标记是老年代占用比例达到阈值时
  • 触发时机:老年代占用堆空间比例达到阈值时,进行并发标记(不会 STW),阈值由-XX:InitialingHeapOccupancyPercent=percent控制,默认45%

接下来是 Old GC 的流程(含 Young GC 阶段),其实把 Old GC 理解为并发周期是比较合理的,不要单纯地认为是清理老年代的区块,因为这一步和年轻代收集也是相关的。下面我们介绍主要流程:

它的GC步骤分2步:

  • 全局并发标记(global concurrent marking)
  • 拷贝存活对象(evacuation)

全局并发标记:

  1. 初始标记:STW。 标记从根节点直接可达的对象,

    • 这个阶段会执行一次年轻代GC,
    • 会产生全局停顿stw。然后对 Survivor 区(root region)进行标记,因为该区可能存在对老年代的引用。

    因为 Young GC 是需要 stop-the-world 的,所以并发周期直接重用这个阶段,虽然会增加 CPU 开销,但是停顿时间只是增加了一小部分。

  2. 扫描根引用区:因为先进行了一次 YGC,所以当前年轻代只有 Survivor 区有存活对象(Eden中没有),它被称为根引用区。扫描 Survivor 到老年代的引用,

    • 该阶段必须在下一次 Young GC 发生前结束。

    这个阶段不能发生年轻代收集,如果中途 Eden 区真的满了,也要等待这个阶段结束才能进行 Young GC。

    ​ G1 GC在初始标记的存活区扫描对老年代的引用,并标记被引用的对象。

    ​ 该阶段与应用程序(非STW)同时运行,并且只有完成该阶段后,才能开始下一次STW年轻代垃圾回收。

    通过RSet标记出上一个阶段标记的region引用到的old区

  3. 并发标记:寻找整个堆的存活对象,

    • 该阶段可以被 Young GC 中断。

    这个阶段是并发执行的,中间可以发生多次 Young GC,Young GC 会中断标记过程

    遍历的范围不再是整个old区,而只需要遍历第二步标记出来的region

  4. 重新标记:stw,完成最后的存活对象标记。因为程序在运行,针对上一次的标记进行修正。使用了比 CMS 收集器更加高效的 snapshot-at-the-beginning (SATB) 算法。

    Oracle 的资料显示,这个阶段会回收完全空闲的区块

    CMS采样的是,G1采用的是快照

  5. 清理:清理阶段真正回收的内存很少。

    清点和重置标记状态,该阶段会STW,这个阶段并不会实际上去做垃圾的收集,等待evacuation阶段来回收。

    直接将整个region的对象拷贝到另一个region。

    这个阶段,G1只选择垃圾较多的region来清理,并不是完全清理

到这里,G1 的一个并发周期就算结束了,其实就是主要完成了垃圾定位的工作,定位出了哪些分区是垃圾最多的。因为整堆一般比较大,所以这个周期应该会比较长,中间可能会被多次 stop-the-world 的 Young GC 打断。

拷贝存活对象:

Evacuation阶段是STW的。该阶段把一部分Region里的活对象拷贝到另一部分Region中,从而实现垃圾的回收清理。

3) Mixed GC

触发时机:当老年代大小占整个堆大小百分比达到该阈值45%时触发。由参数-XX:InitiatingHeapOccupancyPercent=45%决定。默认45%

回收内容:所有young region+一部分old region

这里需要注意:是一部分老年代(回收价值高的,这也是为什么叫g1的原因 ),而不是全部老年代,可以选择哪些old region进行收集,从而可以对垃圾回收的耗时时间进行控制,根据的是最大暂停时间。也要注意的是Mixed GC并不是Full GC。

  • 会对E、S、O进行全面垃圾回收
  • 重新标记remark会STW
  • 拷贝存活会STW
  • G1根据暂停时间有选择地回收,找回收价值高的
4) Full GC

G1的初衷就是要避兔Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-Wor1d),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。

要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到fullgc,这种情况可以通过增大内存解决。

导致G1 Full GC的原因可能有两个:

  • Evacuation的时候没有足够的to-space来存放晋升的对象
  • 并发处理过程完成之前空间耗尽。

下面我们来介绍特殊情况,那就是会导致 Full GC 的情况,也是我们需要极力避免的:

  1. concurrent mode failure:并发模式失败,CMS 收集器也有同样的概念。G1 并发标记期间,如果在标记结束前,老年代被填满,G1 会放弃标记。

    这个时候说明

    • 堆需要增加了,
    • 或者需要调整并发周期,如增加并发标记的线程数量,让并发标记尽快结束
    • 或者就是更早地进行并发周期,默认是整堆内存的 45% 被占用就开始进行并发周期。
  2. 晋升失败:并发周期结束后,是混合垃圾回收周期,伴随着年轻代垃圾收集,进行清理老年代空间,如果这个时候清理的速度小于消耗的速度,导致老年代不够用,那么会发生晋升失败。

    说明混合垃圾回收需要更迅速完成垃圾收集,也就是说在混合回收阶段,每次年轻代的收集应该处理更多的老年代已标记区块。

  3. 疏散失败:年轻代垃圾收集的时候,如果 Survivor 和 Old 区没有足够的空间容纳所有的存活对象。这种情况肯定是非常致命的,因为基本上已经没有多少空间可以用了,这个时候会触发 Full GC 也是很合理的。

    最简单的就是增加堆大小

  4. 大对象分配失败,我们应该尽可能地不创建大对象,尤其是大于一个区块大小的那种对象。

G1相关参数

  • -XX:+UseG1GC:使用G1垃圾收集器

  • XX:MaxGCPauseMillis=200

    设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到),默认值是200毫秒。尽可能保证回收时间小于200ms。

  • -XX:G1HeapRegionSize=n

    设置的G1区域的大小(每个小块多大)。值是2的幂,范围是1MB到32MB之间。目标是根据最小的Java堆大小划分出约2048个区域。

    默认是堆内存的1/2000。

  • -XX:ParallelGCThreads=n

    设置STW工作线程数的值。将n的值设置为逻辑处理器的数量。n的值与逻辑处理器的数量相同,最多为8。

  • -XX:ConcGCThreads=n

    设置并行标记的线程数。将n设置为并行垃圾回收线程数(ParallelGCThreads)的1/4左右。

  • -XX:InitiatingHeapOccupancyPercent=45
    设置触发标记周期的Java堆占用率阈值。默认是45%。

  • -XX:NewRatio=n:老年代/年轻代,默认值 2,即 1/3 的年轻代,2/3 的老年代

    不要设置年轻代为固定大小,否则:

    • G1 不再需要满足我们的停顿时间目标
    • 不能再按需扩容或缩容年轻代大小
  • 参考https://javadoop/post/g1

3.4.6 测试

-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -XX:+PrintGCDetails -Xmx16m

#日志

[GC pause (G1 Evacuation Pause) (young), 0.0046811 secs]
[Parallel Time: 3.7 ms, GC Workers: 4]
[GC Worker Start (ms): Min: 156.2, Avg: 158.1, Max: 159.6, Diff: 3.4]

#扫描根节点
[Ext Root Scanning (ms): Min: 0.0, Avg: 0.1, Max: 0.4, Diff: 0.4, Sum: 0.4]

#更新RS区域所消耗的时间
[Update RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
  [Processed Buffers: Min: 0, Avg: 0.0, Max: 0, Diff: 0, Sum: 0]
[Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]

#对象拷贝
[Object Copy (ms): Min: 0.0, Avg: 1.4, Max: 3.0, Diff: 3.0, Sum: 5.4]
[Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.3, Diff: 0.3, Sum: 0.4]
  [Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 4]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
[GC Worker Total (ms): Min: 0.0, Avg: 1.6, Max: 3.4, Diff: 3.4, Sum: 6.3]
[GC Worker End (ms): Min: 159.6, Avg: 159.7, Max: 159.9, Diff: 0.3]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.0 ms] #清空CardTable
[Other: 0.9 ms]
[Choose CSet: 0.0 ms]  #选取CSet
[Ref Proc: 0.9 ms] #弱引用、软引用的处理耗时
[Ref Enq: 0.0 ms]  #弱引用、软引用的入队耗时
[Redirty Cards: 0.0 ms]
[Humongous Register: 0.0 ms] #大对象区域注册耗时
[Humongous Reclaim: 0.0 ms]  #大对象区域回收耗时
[Free CSet: 0.0 ms]

#年轻代的大小统计
[Eden: 6144.0K(6144.0K)->0.0B(5120.0K) Survivors: 0.0B->1024.0K Heap: 6144.0K(16.0M)->2791.0K(16.0M)]
[Times: user=0.00 sys=0.00, real=0.00 secs] 
3.4.7 G1优化建议
  • 年轻代大小

    • 避免使用-Xmn选项或-XX:NewRatio等其他相关选项显示设置年轻代大小。
    • 固定年轻代的大小会覆盖暂停时间目标。
  • 暂停时间目标不要太过严苛

    • G1 GC的吞吐量目标是90%的应用程序时间和10%的垃圾回收时间。
    • 评估G1 GC的吞吐量时,暂停时间目标不要太严苛。目标太过严苛表示您愿意承受更多的垃圾回收开销,而这会直接影响到吞吐量。

G1新功能

  1. JDK 8u20 字符串去重。

开启:-XX:+UseStringDeduplication

优点:节省大量内存
缺点:略微多占用了 cpu 时间,新生代回收时间略微增加

String s1 = new String("hello"); // char[]{'h','e','l','l','o'}
String s2 = new String("hello"); // char[]{'h','e','l','l','o'}

G1会将所有新分配的字符串放入一个队列。当新生代回收时,G1并发检查队列中是否有字符串重复。如果它们值一样,让它们引用同一个 char[]

注意,与 String.intern() 不一样,String.intern() 关注的是字符串对象,而字符串去重关注的是 char[]
在 JVM 内部,使用了不同的字符串表

  1. JDK 8u40 并发标记类卸载

所有对象都经过并发标记后,就能知道哪些类不再被使用,占用着内存也很浪费内存。

当一个类加载器的所有类都不再使用,则卸载它所加载的所有类 -XX:+ClassUnloadingWithConcurrentMark 默认启用

  1. JDK 8u60 回收巨型对象

巨型对象:一个对象大于 region 的一半的对象。

如下,巨型对象可能占用多个region

老年代的对象引用了巨型对象的话该老年代对象的卡表会被标记为脏的。当某个巨型对象从老年代的引用为0时,他就可以在新生代的垃圾回收时被回收掉。这是为了巨型对象越早回收越好。

G1 不会对巨型对象进行拷贝
G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为0 的巨型对象就可以在新生代垃圾回收时处理掉

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

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

垃圾回收调优

调优领域

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

确定目标
【低延迟】还是【高吞吐量】,选择合适的回收器

  • 低延迟:CMS ,G1,ZGC
  • 高吞吐量:ParallelGC
  • Zing

5.3 最快的 GC

低延迟

查看 FullGC 前后的内存占用,考虑下面几个问题
数据是不是太多?
resultSet = statement.executeQuery(“select * from 大表 limit n”)查大表前查出来很占内存,所以用法limit限制一下
数据表示是否太臃肿?
对象图
对象大小 16 Integer 24 int 4
是否存在内存泄漏?
static Map map =
软、弱
第三方缓存实现

5.4 新生代调优
新生代的特点

  • 所有的 new 操作的内存分配非常廉价
    TLAB thread-local allocation buffer
  • 死亡对象的回收代价是零
  • 大部分对象用过即死
  • 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.
新生代能容纳所有【并发量 * (请求-响应)】的数据
幸存区大到能保留【当前活跃对象 +需要晋升对象】
晋升阈值配置得当,让长时间存活对象尽快晋升

  • 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

5.5 老年代调优
以 CMS 为例
CMS 的老年代内存越大越好
先尝试不做调优,如果没有 Full GC 那么已经…,否则先尝试调优新生代
观察发生 Full GC 时老年代内存占用,将老年代内存预设调大 1/4 ~ 1/3

  • XX:CMSInitiatingOccupancyFraction=percent

5.6 案例
案例 1 Full GC 和 Minor GC频繁
案例 2 请求高峰期发生 Full GC,单次暂停时间特别长 (CMS)
案例 3 老年代充裕情况下,发生 Full GC (CMS jdk1.7)

SerialGC

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

ParallelGC

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

CMS

  • 新生代内存不足发生的垃圾收集 - minor gc
  • 老年代内存不足:
    • 垃圾回收速度跟不上垃圾产生速度时,并发收集失败,此时CMS会退化为串行收集,

G1

  • 新生代内存不足发生的垃圾收集 - minor gc
  • 老年代内存不足
    • 触发时机:老年代与整个堆占比达到阈值45%,触发并发标记及混合收集。如果并发收集比垃圾产生快,这时还不叫full GC。但也会有重标记、拷贝的过程,暂停时间短
    • 重新标记后的筛选回收:筛选回收(stop the world事件 根据用户期望的GC停顿时间回收)(注意:CMS 在这一步不需要stop the world)(阿里问为何停顿时间可以设置,参考:G1 垃圾收集器架构和如何做到可预测的停顿(阿里)

4 可视化GC日志分析工具

4.1 GC日志输出参数

前面通过-XX:+PrintGCDetails可以对GC日志进行打印,我们就可以在控制台查看,这样虽然可以查看到GC的信息,但是并不直观,可以借助于第三方的GC日志分析工具进行查看。

在日志打印输出涉及到的参数如下:

可选值:
‐XX:+PrintGC 输出GC日志
‐XX:+PrintGCDetails 输出GC的详细日志
‐XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
‐XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2019‐05‐04T21:53:59.234+0800)
‐XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
‐Xloggc:../logs/gc.log 日志文件的输出路径

测试:

-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xmx256m 
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps 
-XX:+PrintGCDateStamps -XX:+PrintHeapAtGC 
-Xloggc:F://test//gc.log
# 运行后就可以在F盘下生成gc.log文件

日志中,GCFull GC表示的是GC的类型。GC只在新生代进行,Full GC包括新生代和老年代、方法区。
Allocation FailureGC发生的原因,一般新生代的GC发生的原因都是Eden区空间不够,不足以用来创建新的对象。
80832k -> 19298k: 堆回收之前和回收之后剩余的空间的大小。

4.2 GC Easy可视化工具

GC Easy是一款在线的可视化工具,易用、功能强大,网站:http://gceasy.io/

上传后,点击“Analyze”按钮,即可查看报告

下面是堆大小

1_串行
-XX:+UseSerialGC 相当于 Serial + SerialOld。复制+标记整理

-XX:+UseParallelGC ~ -XX:+UseParallelOldGC(默认)
-XX:GCTimeRatio=ratio调整垃圾回收与总时间的占比1/1+ration
-XX:MaxGCPauseMillis=ms
-XX:ParallelGCThreads=n
开启一个另一个就自动开启了。
多个垃圾回收同时进行。个数等于CPU个数。垃圾回收时候CPU利用率是100%。可以指定GC线程数。调整新生代的大小。
-XX:+UserAdaptiveSizePolicy动态调整新生代大小,伊甸园和幸存区比例,晋升阈值

3_响应时间优先

-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
-XX:ParallelGCThreads=n ~ -XX:ConcGCThreads=threads
-XX:CMSInitiatingOccupancyFraction=percent
-XX:+CMSScavengeBeforeRemark

并发的,而不是并行的。 用户进程与垃圾回收进程是并发的,都会抢占CPU。

Hotspot JVM提供多种垃圾回收器,我们需要根据具体应该的需要采用不同的回收器

垃圾回收器的并行和并发:

  • 并行Parallel:指多个收集器的线程同时工作,但是用户线程处于等待状态
  • 并发concurrent:指收集器在工作的时候,可以运行用户线程工作。
    • 并发不代表解决了GC停顿的问题,在关键的步骤还是要停顿。比如在收集器标记垃圾的时候。但在清除垃圾的时候,用户线程可以和GC线程并发执行。
内存泄露的经典原因

Java内存泄露的经典原因:

  • 对象定义在错误的范围(Wrong Scope)
  • 异常处理不当
  • 集合数据管理不当

对象定义在错误的范围:

//如果Foo实例对象的声明较长,会导致临时性内存泄露(这里的names变量其实只有临时作用)
class Foo{
    private String[] names;
    public void doIt(int length){
        if(names=null || names.length<length){
            names=new String[length];
        }
        popolate(names);
        print(names);
    }//names只在doIt这个方法中使用,没必要定义在外面,定义在外面的话Foo对象存在还会占用空间。
}
//JVM喜欢生命周期短的对象,这样做已经足够高效:将成员变量转换成局部变量
class Foo{
    public void doIt(int length){
    String[] names=new String([length]);
    populate(names);
    print(names);
    }
}

异常处理不当:

//错误的用法
数据库连接的关闭close应该放到finally中

集合数据管理不当:

当使用Array-based的数据结构(ArrayList,HashMap等)时,尽量减少resize
    比如new ArrayList时,尽量估算size,在创建的时候确定size
    减少resize可以避免没有必要的aray copying,gc碎片等问题
如果一个List只需要顺序访问,不需要随机访问,用LinkedList代替ArrayList,LinkedList是链表,不需要resize
//-verbose:gc输出详细垃圾回收日志//回收前和回收后情况
//-Xms20M堆初始大小
//-Xmx20M堆最大大小
//-Xmn10M堆新生代大小
//-XX:+PrintGCDetails//各个堆信息
//-XX:SurvivorRatio=8//Eden8:1:1
int size=1024*1024;//1M
byte[] myAlloc1=new byte[2*size];
byte[] myAlloc2=new byte[2*size];
byte[] myAlloc3=new byte[2*size];
byte[] myAlloc4=new byte[2*size];

5_字节码

java虚拟机不和包括java在内的任何语言绑定,它只与“Class”特定的二进制文件格式关联,Class文件中包含Java虚拟机指令集和符号表以及若干其他辅助信息。本文将以字节码的角度来研究Java虚拟机。

字节码

  • Java跨平台的原因是JVM不跨平台
  • 首先编写一个简单的java代码,一次为例进行讲解

方法的执行过程:

  • 原始java代码

  • 编译后的字节码文件

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

  • 方法字节码载入方法区

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

  • 执行引擎开始执行字节码

测试代码(原始java代码)

package JVMtest;

public class MyTest1{
    private int a=1;
    public int getA(){
        return a;
    }
    public void setA(int a){
        this.a=a;
    }
}

编译生成MyTest1.class文件:使用反编译命令:javap MyTest1 ,对文件进行反编译,生成以下数据

Compiled from "MyTest1.java"
public class JVMtest.MyTest1 {
  public JVMtest.MyTest1();
  public int getA();
  public void setA(int);
}

增加参数,使用反编译命令:javap -c MyTest1,生成以下数据

Compiled from "MyTest1.java"
public class JVMtest.MyTest1 {
  public JVMtest.MyTest1();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: aload_0
       5: iconst_1
       6: putfield      #2                  // Field a:I
       9: return

  public int getA();
    Code:
       0: aload_0
       1: getfield      #2                  // Field a:I
       4: ireturn

  public void setA(int);
    Code:
       0: aload_0
       1: iload_1
       2: putfield      #2                  // Field a:I
       5: return
}

javap -v查看(二进制字节码)

使用反编译命令:javap -verbose MyTest1.class,生成以下数据

javap -verbose MyTest1.class
Classfile /F:/JVMtest/out/production/JVMtest/JVMtest/MyTest1.class
  Last modified 2020-3-30; size 461 bytes
  MD5 checksum f4687563763f0dcca1cd899030c582fb
  Compiled from "MyTest1.java"
public class JVMtest.MyTest1
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#21         // JVMtest/MyTest1.a:I
   #3 = Class              #22            // JVMtest/MyTest1
   #4 = Class              #23            // java/lang/Object
   #5 = Utf8               a
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               LJVMtest/MyTest1;
  #14 = Utf8               getA
  #15 = Utf8               ()I
  #16 = Utf8               setA
  #17 = Utf8               (I)V
  #18 = Utf8               SourceFile
  #19 = Utf8               MyTest1.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = NameAndType        #5:#6          // a:I
  #22 = Utf8               JVMtest/MyTest1
  #23 = Utf8               java/lang/Object
{
  public JVMtest.MyTest1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_1
         6: putfield      #2                  // Field a:I
         9: return
      LineNumberTable:
        line 3: 0
        line 4: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  this   LJVMtest/MyTest1;

  public int getA();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: getfield      #2                  // Field a:I
         4: ireturn
      LineNumberTable:
        line 6: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LJVMtest/MyTest1;

  public void setA(int);
    descriptor: (I)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: iload_1
         2: putfield      #2                  // Field a:I
         5: return
      LineNumberTable:
        line 9: 0
        line 10: 5
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  this   LJVMtest/MyTest1;
            0       6     1     a   I
}
SourceFile: "MyTest1.java"

字节码分析

常量池分析

使用UltraEdit打开MyTest1.class二进制文件:

如下,00 18(即24)代表常量池有#1-#24项,注意#0项不计入,也没有值。

每个常量分为2/3个部分,比如方法的常量格式为u1,u2,u3

如下面第一个u1=0A(十进制的10),根据后面常量池的表可以查到0A代表的是一个方法的引用,此时结构后面还跟着2个值,而#4和#20又对应别的常量池,最终得到#1代表方法的【返回值类型】和【方法名+参数】的常量池号,

网站:http://www.ab126.com/GOJU/1711.HTML
经测试,将16进制转换为ASCII后如下,刚好是javap -verbose生成Constant pool的结果
#1 = Methodref          #4.#20         // java/lang/Object."<init>":()V
#2 = Fieldref           #3.#21         // JVMtest/MyTest1.a:I
#3 = Class              #22            // JVMtest/MyTest1
#4 = Class              #23            // java/lang/Object

#5, 61 == a
#6, 49 == I
#7, 3C 69 6E 69 74 3E == <init>
#8, 28 29 56 == ()V代表无参返回值void
#9, 43 6F 64 65 == Code
#10, 4C 69 6E 65 4E 75 6D 62 65 72 54 61 62 6C 65 == LineNumberTable
#11, 4C 6F 63 61 6C 56 61 72 69 61 62 6C 65 54 61 62 6C 65 == LocalVariableTable
#12, 74 68 69 73 == this
#13, 4C 4A 56 4D 74 65 73 74 2F 4D 79 54 65 73 74 31 3B == LJVMtest/MyTest1;
#14, 67 65 74 41 == getA
#15, 28 29 49 == ()I代表无参返回值为int
#16, 73 65 74 41 == setA
#17, 28 49 29 56 == (I)V代表参数为int,返回值void
#18, 53 6F 75 72 63 65 46 69 6C 65 == SourceFile
#19, 4D 79 54 65 73 74 31 2E 6A 61 76 61 == MyTest1.java
##20,名称#7和类型#8
##21,名称#5和类型#6
#22, 4A 56 4D 74 65 73 74 2F 4D 79 54 65 73 74 31 == JVMtest/MyTest1
#23, 6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A 65 63 74 ==java/lang/Object

字节码结构

1.使用javap -verbose MyTest 命令分析一个字节码文件时,将会分析该字节码文件的魔数,版本号,常量池,类信息,类的构造方法,类中的方法信息,类变量与成员变量的信息

魔数

2.魔数:所有的.class文件的前四个字节都是魔数,魔数值为固定值:0xCAFEBABE(咖啡宝贝)

版本号

3.版本号:魔数后面4个字节是版本信息,前两个字节表示minor version(次版本号),后两个字节表示major version(主版本号),十六进制34=十进制52。所以该文件的版本号为1.8.0。低版本的编译器编译的字节码可以在高版本的JVM下运行,反过来则不行。

常量池

4.常量池(constant pool):版本号之后的就是常量池入口,一个java类定义的很多信息都是由常量池来维护和描述的,可以将常量池看作是class文件的资源仓库,包括java类定义的方法和变量信息,常量池中主要存储两类常量:字面量和符号引用。字面量如文本字符串、java中生命的final常量值等,符号引用如类和接口的全局限定名,字段的名称和描述符,方法的名称和描述符等。

5.常量池的整体结构:Java类对应的常量池主要由常量池数量和常量池数组两部分共同构成,常量池数量紧跟在主版本号后面,常量池数量占据两个字节,而常量池数组在常量池数量之后。常量池数组与一般数组不同的是,常量池数组中元素的类型、结构都是不同的,长度当然也就不同,但是每一种元素的第一个数据都是一个u1类型标志位,占据一个字节,JVM在解析常量池时,就会根据这个u1类型的来获取对应的元素的具体类型。 值得注意的是,常量池数组中元素的个数=常量池数-1,(其中0暂时不使用)。目的是满足某些常量池索引值的数据在特定的情况下需要表达不引用任何常量池的含义。根本原因在于索引为0也是一个常量,它是JVM的保留常量,它不位于常量表中。这个常量就对应null,所以常量池的索引从1而非0开始。

常量池结构表

以前面的#1为例,第1字节u1=10,23字节u2代表类,45字节u2代表名字和类型

  • u2的23字节又指向了#4,第二个u2的56字节指向#20
    • #4的u1=7是类名指向#23(字符串java/lang/Object),
    • #20的u1=12是又分为
      • 方法名称(指向#7字符串"")+
      • 方法描述符(指向#8字符串()V,代表无参返回值void)。
      • #20最终为"<init>":()V

所以#1最终为java/lang/Object."<init>":()V,其中.为U的分割号

总结:最后都会指向字符串

此外分析#2最后的结果为JVMtest/MyTest1.a:I,代表是一个属性a,类型为int

所以上图的MethodRef代表着一个方法,FieldRef代表是一个属性,其余的是一些基本类型为utf8字符串

类型表示

6.在JVM规范中,每个变量/字段都有描述信息,主要的作用是描述字段的数据类型,方法的参数列表(包括数量、类型和顺序)与返回值。根据描述符规则,

  • 基本数据类型和代表无返回值的void类型都用一个大写字符来表示,
  • 而对象类型使用字符L+对象的全限定名称来表示。
  • 为了压缩字节码文件的体积,对于基本数据类型,JVM都只使用一个大写字母来表示。如下所示:B-byte,C-char,D-double,F-float,I-int,J-long,S-short,Z-boolean,V-void,L-对象类型,
  • 如Ljava/lang/String;
    对于数组类型来说,每一个维度使用一个前置的[ 来表示,如int[]表示为[I ,String [][]被记录为[[Ljava/lang/String;

7.用描述符描述方法的时候,用先参数列表后返回值的方式来描述。参数列表按照参数的严格顺序放在一组()之内,如方法String getNameByID(int id ,String name)转换成(I,Ljava/lang/String;)Ljava/lang/String;

Java字节码整体结构:

Class字节码中有两种数据类型:
(1)字节数据直接量:这是基本的数据类型。共细分为u1、u2、u4、u8四种,分别代表连续的1个字节、2个字节、4个字节、8个字节组成的整体数据。
(2)表/数组:表是由多个基本数据或其他表,按照既定顺序组成的大的数据集合。表是有结构的,它的结构体:组成表的成分所在的位置和顺序都是已经严格定义好的。

访问权限Access Falgs:

2个字节,访问标志信息包括了该class文件是类还是接口,是否被定义成public,是否是abstract,如果是类,是否被定义成final。

  • 0x0021是0x0020和0x0001的并集,表示ACC_PUBLIC和ACC_SUPER。(我们的字节码文件正是21,可以调用父类方法)
  • 0x0002:private
类名

2个字节,对应常量池#3

父类名

2个字节,对应常量池#4

接口

u2接口数量+u2接口名。我们的是0000,没有接口。

字段表Fields

u2+每字段结构*个数

字段表用于描述类和接口中声明的变量。这里的字段包含了类级别变量和实例变量,但是不包括方法内部声明的局部变量。

每字段结构

类型名称数量
u2access-flags1
u2names-index1
u2descriptor-index1
u2attributes-count1
attribute-infoattributesattributes-count
  • 成员个数:00 01代表有一个成员
  • 第一个成员:
    • 成员属性access-flags:00 02:代表private
    • 成员名称names-index:00 05:代表#5(a)
    • 成员类型descriptor-index:00 06:代表#6(I即int)
    • 属性个数attributes-count:00 00
方法

方法个数(2字节)+每方法结构*个数(我们的class为3个get+set+构造器)

每方法的结构:

类型名称数量
u2access-flags1
u2names-index1
u2descriptor-index1
u2attributes-count1
attribute-infoattributesattributes-count

方法个数:00 03:3个方法set+get+构造器

{
  public JVMtest.MyTest1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_1
         6: putfield      #2                  // Field a:I
         9: return
      LineNumberTable:
        line 3: 0
        line 4: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  this   LJVMtest/MyTest1;

  public int getA();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: getfield      #2                  // Field a:I
         4: ireturn
      LineNumberTable:
        line 6: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LJVMtest/MyTest1;

  public void setA(int);
    descriptor: (I)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: iload_1
         2: putfield      #2                  // Field a:I
         5: return
      LineNumberTable:
        line 9: 0
        line 10: 5
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  this   LJVMtest/MyTest1;
            0       6     1     a   I
}
SourceFile: "MyTest1.java"
  • 方法1:
    • 访问权限access-flags:00 01:代表PUBLIC
    • 方法名names-index:00 07:#7(构造器<init>
    • 方法修饰符descriptor-index:00 08:#8(()V
    • 方法属性个数attributes-count:00 01:1个
    • 第一个属性attribute_info:
      • 属性名attribute_name_index:00 09:#9(方法的属性Code,方法总是有Code这个属性)
      • 属性长度attribute_length:00 00 00 38:56个字节,56个字节后是第二个方法
      • info[56]
        • 操作数最大深度max-stack:00 02
        • 局部变量数量max_locals:00 01
        • 方法字节长度code_length:00 00 00 0A:10
        • 往后00 00 00 0A个字节:方法运行时候的字节码:2A B7 00 01 2A 04 B5 00 02 B1,对应jclassLib中的ByteCode信息(init方法):0 aload_0【2A:索引为0推送到栈顶】 1 invokespecial #1 <java/lang/Object.<init>>【B7:调用父类构造方法00 01:#1】 4 aload_0【2A】 5 iconst_1【04】 6 putfield #2 <JVMtest/MyTest1.a>【B5 /00 02】 9 return
        • 异常表00 00
        • Code属性个数:00 02(LineNumberTable和LocalVaribaleTable)
        • 00 0A:#10:LineNumberTable,字节码与源代码的行号对应
        • 00 00 00 0A:往后10个字节
        • 00 02 /00 00 00 03 /00 04 00 04
        • 00 0B:#11:LocalVaribaleTable
        • 00 00 00 0C往后12字节是LocalVaribaleTable
        • 00 01局部变量个数/ 00 00局部变量起始位置. 00 0A结束位置 /00索引 /0C局部变量对应#10this /00 0D局部变量的描述#13/ 00 00检查
        • 对应java里非静态方法,至少有一个局部变量this
  • 方法2:…省略
  • 方法3:…省略

方法中的每个属性都是一个attribute_info结构:

(1)JVM预定义了部分attribute,但是编译器自己也可以实现自己的attribute写入class文件里,供运行时使用;
(2)不同的attribute通过attribute_name_index来区分。

attribute_info格式:

attribute_info{
    u2 attribute_name_index;//eg.Code
    u4 attribute_length;
    u1 info[attribute_length]
}

Code结构:

attribute_name_index值为code,则为Code结构

Code的作用是保存该方法的结构,所对应的的字节码

Code_attribute{//info
    //u2 attribute-name-index;
    //u4 attibute-length;
    u2 max-stack;
    u2 max-locals;
    u4 code-length;
    u1 code[code-length];//往后code-length个字节是ByteCode
    u2 exception-table-length;
    {
        u2 start-pc;
        u2 end-pc;
        u2 handler-pc;
        u2 catch-type;
    }exception-table[exception-table-length];
    u2 attibute-count;
    attribute-info attributes[attibutes-count];
}

构造器的Code结构组成:

  • attribute_length:表示attribute所包含的字节数,不包含attribute_name_index和attribute_length字段
  • max_stacks:表示这个方法运行的任何时刻所能达到的操作数栈的最大深度
  • max_locals:表示方法执行期间创建的局部变量的数目,包含用来表示传入的参数的局部变量
  • code_length:表示该方法所包含的字节码的字节数以及具体的指令码。具体的字节码是指该方法被调用时,虚拟机所执行的字节码
  • exception_table:存放处理异常的信息,每个exception_table表,是由start_pc、end_pc、hangder_pc、catch_type组成
    • start_pc、end_pc:表示在code数组中从start_pc到end_pc(包含start_pc,不包含end_pc)的指令抛出的异常会由这个表项来处理
    • hangder_pc:表示处理异常的代码的开始处。
    • catch_type:表示会被处理的异常类型,它指向常量池中的一个异常类。当catch_type=0时,表示处理所有的异常。

附加方法其他属性:

LineNumbeTable_attribute:

LineNumberTable_attribute{
    u2 attribute-name-index;
    u4 attribute-length;
    u2 line-number-table-length;
    {u2 start-pc;
     u2 line-number;
    }line-number-table[line-number-table-length];
}

这个属性表示code数组中,字节码与java代码行数之间的关系,可以在调试的时候定位代码执行的行数。

LocalVariableTable :结构类似于 LineNumbeTable_attribute
对于Java中的任何一个非静态方法,至少会有一个局部变量,就是this。

public JVMtest.MyTest1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_1
         6: putfield      #2                  // Field a:I
         9: return
      LineNumberTable:
        line 3: 0
        line 4: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      10     0  this   LJVMtest/MyTest1;

  public int getA();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: getfield      #2                  // Field a:I
         4: ireturn
      LineNumberTable:
        line 6: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LJVMtest/MyTest1;

  public void setA(int);
    descriptor: (I)V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: iload_1
         2: putfield      #2                  // Field a:I
         5: return
      LineNumberTable:
        line 9: 0
        line 10: 5
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  this   LJVMtest/MyTest1;
            0       6     1     a   I
}

字节码查看工具:jclasslib
http://github/ingokegel/jclasslib

测试2 ------- 反编译分析MyTest2.class
static变量会导致出现static代码块

public class MyTest2{
    String str="Welcome";
    private int x=5;
    public static Integer in=5;
    public static void main(String[] args){
        MyTest2  myTest2=new MyTest2();
        myTest2.setX(8);
        in=20;
    }
    private synchronized void setX(int x){
        thisx=x;
    }
}

javap -verbose -p Abc -p:将private修饰的方法显示出来
synchronized关键字:
moniterenter
monitorexit

测试3

public class MyTest3{
    public void test(){
        try{
            InputStream is=new FileInputStream("test.txt");
            ServerSocket ss=new ServerSocket(9999);
            ss.accept();

        }catch(FileNotFoundException e){

        }catch(IOException e){

        }catch(Exception e){

        }finally{
            System.out.println("finally");
        }
    }
}

Java字节码对于异常的处理方式:

1.统一采用异常表的方式来对异常进行处理;
2.在jdk1.4.2之前的版本中,并不是使用异常表的方式对异常进行处理的,而是采用特定的指令方式;
3.当异常处理存在finally语句块时,现代化的JVM采取的处理方式是将finally语句内的字节码拼接到每个catch语句块后面。也就是说,程序中存在多少个catch,就存在多少个finally块的内容。
栈帧(stack frame):
用于帮助虚拟机执行方法调用和方法执行的数据结构
栈帧本身是一种数据结构,封装了方法的局部变量表,动态链接信息,方法的返回地址以及操作数栈等信息。
符号引用:符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用和虚拟机的布局无关。(在编译的时候一个每个java类都会被编译成一个class文件,但在编译的时候虚拟机并不知道所引用类的地址,多以就用符号引用来代替,而在这个解析阶段就是为了把这个符号引用转化成为真正的地址的阶段。)
直接引用:(1)直接指向目标的指针(指向对象,类变量和类方法的指针)(2)相对偏移量。(指向实例的变量,方法的指针)(3)一个间接定位到对象的句柄。
有些符号引用在加载阶段或者或是第一次使用时,转换为直接引用,这种转换叫做静态解析;另外一些符号引用则是在运行期转换为直接引用,这种转换叫做动态链接。

测试4

public class MyTest4{
    public static void test(){
            System.out.println("static test");
    }
    public static void main(Stirng[] args){
        test();             //invokestatic
    }
}

静态解析的四种场景:静态方法、父类方法、构造方法、私有方法。以上四种方法称为非虚方法,在类加载阶段将符号引用转换为直接引用。

JVM调优

请看:https://blog.csdn/hancoder/article/details/108312012

张龙]类的加载过程代码:

  • 测试1:
/**
        对于静态字段来说,只有直接定义了该字段的类才会被初始化
        当一个类在初始化时,要求父类全部都已经初始化完毕
        -XX:+TraceClassLoading,用于追踪类的加载信息并打印出来

        -XX:+<option>,表示开启option选项
        -XX:-<option>,表示关闭option选项
        -XX:<option>=value,表示将option的值设置为value
*/
public class MyTest{
    public static void main(String[] args){
        System.out.println(MyChild.str);  //输出:MyParent static block 、 hello world   (因为对MyChild不是主动使用)
       //对parent的主动使用,因为没有使用到child中的str2,所以child的静态代码块没有执行。子类引用不算主动使用
        System.out.println(MyChild.str2);  //输出:MyParent static block  、MyChild static block、welcome
        
 /* 输出
MyParent static block
hello world
MyChild static block
welcome
  */
    }
}
class MyParent{
    public static String str="hello world";
    static {
        System.out.println("MyParent static block");
    }
}
class MyChild extends MyParent{
    public static String str2="welcome";
    static {
        System.out.println("MyChild static block");
    }
}
  • 测试2:
/** javap -c 类
        常量在编译阶段会存入到调用这个常量的方法所在的类的常量池中
        本质上,调用类并没有直接调用到定义final常量的类,因此并不会触发定义常量的类的初始化
        注意:这里指的是将常量存到MyTest2的常量池中,之后MyTest2和MyParent就没有任何关系了。
        甚至我们可以将MyParent2的class文件删除

        助记符 ldc:表示将int、float或者String类型的常量值从常量池中推送至栈顶
        助记符 bipush:表示将单字节(-128-127)的常量值推送到栈顶
        助记符 sipush:表示将一个短整型值(-32768-32369)推送至栈顶
        助记符 iconst_1:表示将int型的1推送至栈顶(iconst_m1到iconst_5)
*/
public class MyTest2{
    public static void main(String[] args){
        System.out.println(MyParent2.str);    //输出 hello world
        System.out.println(MyParent2.s);  
        System.out.println(MyParent2.i);  
        System.out.println(MyParent2.j);  
  /*
hello world
7
129
1
   */
    }
}
class MyParent2{
    public static final String str="hello world";
    public static final short s=7;
    public static final int i=129;
    public static final int j=1;
    static {
        System.out.println("MyParent static block");
    }
}
  • 测试3
/**
但是常量也有例外:
当一个常量的值并非编译期间可以确定的,那么其值就不会放到调用类的常量池中
这时在程序运行时,会导致主动使用这个常量所在的类,显然会导致这个类被初始化
*/
public class MyTest3{
    public static void main(String[] args){
        System.out.println(MyParent3.str);  
        /*  输出
        MyParent static block //触发了类的初始化
        kjqhdun-baoje21w-jxqioj1-2jwejc9029
        */
    }
}
class MyParent3{
    public static final String str=UUID.randomUUID().toString();
    static {
        System.out.println("MyParent static block");
    }
}
  • 测试4
/**
对于数组实例来说,其类型是由JVM在运行期动态生成的,表示为 [L com.hisense.classloader.MyParent4 这种形式。
对于数组来说,JavaDoc经构成数据的元素成为Component,实际上是将数组降低一个维度后的类型。
一维/二维数组的getSuperClass都是Object

助记符:anewarray:表示创建一个引用类型(如类、接口)的数组,并将其引用值压入栈顶
助记符:newarray:表示创建一个指定原始类型(int boolean float double)d的数组,并将其引用值压入栈顶
    */
public class MyTest4{
    public static void main(String[] args){
        MyParent4[] myParent4s=new MyParent4[1];    //不是主动使用
        System.out.println("--------");
        MyParent4 myParent4=new MyParent4();        //创建类的实例,属于主动使用,会导致类的初始化

        System.out.println(myParent4s.getClass());  //输出 [L com.hisense.classloader.MyParent4
        System.out.println(myParent4s.getClass().getSuperClass());    //输出Object

        int[] i=new int[1];
        System.out.println(i.getClass());          //输出 [ I
        System.out.println(i.getClass().getSuperClass());    //输出Object
    }
    /*
------------
MyParent static block
class [LJVMtest.MyParent4;
class java.lang.Object
class [I
class java.lang.Object
    */
}
class MyParent4{
    static {
        System.out.println("MyParent static block");
    }
}
  • 测试5
/**
        当一个接口在初始化时,并不要求其父接口都完成了初始化
        只有在真正使用到父接口的时候(如引用接口中定义的常量),才会初始化
*/
public class MyTest5{
    public static void main(String[] args){
         public static void main(String[] args){
            System.out.println(MyChild5.b)
         }
    }
}
interfacce MParent5{
    public static Thread thread=new thread(){
        System.out.println(" MParent5 invoke")
    };
}
interface MyChild5 extends MParent5{     //接口属性默认是 public static final
    public static int b=6;
}
  • 测试6
/**
 准备阶段和初始化的顺序问题
*/
public class MyTest6{
    public static void main(String[] args){
         public static void main(String[] args){
            Singleton Singleton=Singleton.getInstance();
            System.out.println(Singleton.counter1);     //输出1,1
            System.out.println(Singleton.counter2);
         }
    }
}
class Singleton{
    public static int counter1;
    public static int counter2=0;  
    private static Singleton singleton=new Singleton();
    
    private Singleton(){
        counter1++;
        counter2++;
    }
    
    // public static int counter2=0;       //   若改变此赋值语句的位置,输出  1,0
    public static Singleton getInstance(){
        return singleton;
    }
}

java编译器在它编译的每一个类都至少生成一个实例化的方法,在java的class文件中,这个实例化方法被称为<init>。针对源代码中每一个类的构造方法,java编译器都会产生一个“<init>”方法。

测试7

/** //类加载器测试
 java.lang.String是由根加载器加载,在rt.jar包下
*/
public class MyTest7{
    public static void main(String[] args){
         public static void main(String[] args){
            Class<?> clazz=Class.forName("java.lang.String");
            System.out.println(clazz.getClassLoader());  //返回null
            
            Class<?> clazz2=Class.forName("C");
           System.out.println(clazz2.getClassLoader());  //输出sun.misc.Launcher$AppClassLoader@18b4aac2  其中AppClassLoader:系统应用类加载器
         }
    }
}
class C{
}
  • 测试8
/**
    调用ClassLoader的loaderClass方法加载一个类,并不是对类的主动使用,不会导致类的初始化
*/
public class MyTest8{
    public static void main(String[] args){
        ClassLoader loader=ClassLoader.getSystemClassLoader();
        Class<?> clazz1=loader.loadClass("CL"); //不会初始化
        System.out.println(clazz1);
        System.out.println("-------------------");

        Class<?> clazz=Class.forName("CL");
        System.out.println(clazz);  //反射初始化
        
/*
class another.CL
-------------------
FinalTest static block
class another.CL
 */
    }
}

class CL{
    static {
        System.out.println("FinalTest static block");
    }
}
  • 测试9-12忽略
  • 测试13
/**
    输出AppClassLoader、ExtClassLoader、null
*/
public class MyTest13{
    public static void main(String[] args){
         public static void main(String[] args){
            ClassLoader loader=ClassLoader.getSystemClassLoader();
            System.out.println(loader);
            
            while(loader!=null){
                loader=loader.getParent();
                 System.out.println(loader);
            }
         }
    }
}
  • 测试14
public class MyTest14{
    public static void main(String[] args){
         public static void main(String[] args){
            ClassLoader loader=Thread.currentThread().getContextClassLoader();
            System.out.println(loader);         //输出AppClassLoader
            //下面这段没整明白什么用,先记录下来
            String resourceName="com/hisense/MyTest13.class";
            Enumeration<URL> urls=loader.getResources(resourceName);
            whilr(urls.hasMoreElements()){
                URL url=urls.nextElement();
                System.out.println(url);
            }
         }
    }
}

测试15

/**
    对于数组,它对应的class对象不是由类加载器加载,而是由JVM在运行期动态的创建。然而对于数组类的类加载器来说,它返回的类加载器和数组内元素的类加载器是一样的。如果数组类元素是原生类,那么数组是没有类加载器的。
*/
public class MyTest15{
    public static void main(String[] args){
            String[] strings=new String[2];
            System.out.println(strings.getClass());
            System.out.println(strings.getClass().getClassLoader());    //输出null
            
            MyTest15[] mytest15=new MyTest15[2];
            System.out.println(mytest15.getClass().getClassLoader());   //输出应用类加载器
            
            int[] arr=new int[2];
            System.out.println(arr.getClass().getClassLoader());        //输出null,此null非彼null
    }
}

并行类加载器可支持并发加载,需要在类初始化期间调用ClassLoader.registerAaParallelCapable()方法进行注册。ClassLoader类默认支持并发加载,但是其子类必须在初始化期间进行注册。

loadClass()包含如下方法

  • findLoadedClass(String);//检查是否已经被加载

  • loadClass();

  • findClass();//我们重写的

  • 测试16

/**
    创建自定义加载器,继承ClassLoader
*/
public class MyTest16 extends ClassLoader{
    private String classLoaderName;
    private String path;
    private final String fileExtension=".class";
    
    public MyTest16(String classLoaderName){
        super();        //将系统类当做该类的父加载器
        this.classLoaderName=classLoaderName;
    }
    public MyTest16(ClassLoader parent,String classLoaderName){
        super(parent);      //显式指定该类的父加载器
        this.classLoaderName=classLoaderName;
    }
    
   public MyTest16(ClassLoader parent){
        super(parent);      //显式指定该类的父加载器
    }
    
    public void setPath(String path){//指定加载的路径(系统加载器路径为out目录)
        this.path=path;
    }
    @Override
    protect Class<?> findClass(String className){
        System.out.println("calssName="+className);
        className=className.replace(".",File.separator);
        byte[] data=loadClassData(className);
        return defineClass(className,data,0,data.length); //define方法为父类方法
    }
    
    private byte[] loadClassData(String name){
        InputStream is=null;
        byte[] data=null;
        ByteArrayOutputStream baos=null;
        try{
            is=new FileInputStream(new File(this.path+name+this.fileExtension));
            baos=new ByteArrayOutputStream();
            int ch;
            while(-1!=(ch=is.read())){
                baos.write(ch);
            }
            data=baos.toByteArray();
            
        }catch(Exception e){
        }finally{
            is.close();
            baos.close();
             return data;
        }
    }
    public static void test(ClassLoader classLoader){
        Class<?> clazz=classLoader.loadClass("com.hisense.MyTest1");  //loader不一样,但是类一样
        //loadClass()是父类方法,在方法内部调用findClass
        System.out.println(clazz.hashCode());
        Object  object=clazz.newInstance();
        System.out.println(object);
    }
    public static void main(String[] args){
        //父亲是系统类加载器,根据父类委托机制,MyTest1由系统类加载器加载了
        MyTest16 loader1=new MyTest16("loader1");       
        test(loader1);
        
        //仍然是系统类加载器进行加载的,因为路径正好是classpath
        MyTest16 loader2=new MyTest16("loader2");  //如果都是由系统加载器加载的,那么class就一样
        loader2.path="D:\Eclipse\workspace\HiATMP-DDMS\target\classes\";
        test(loader2);
        
         //自定义的类加载器被执行,findClass方法下的输出被打印。前提是当前classpath下不存在MyTest1.class,MyTest16的父加载器-系统类加载器会尝试从classpath中寻找MyTest1。
        MyTest16 loader3=new MyTest16("loader3");  
        loader3.path="C:\Users\weichengjie\Desktop\";//
        test(loader3);
        
        //与3同时存在,输出两个class的hash不一致,findClass方法下的输出均被打印,原因是类加载器的命名空间问题。
        MyTest16 loader4=new MyTest16("loader4");  
        loader4.path="C:\Users\weichengjie\Desktop\";
        test(loader4);
        
        //将loader3作为父加载器
        MyTest16 loader5=new MyTest16(loader3,"loader3");  
        loader3.path="C:\Users\weichengjie\Desktop\";
        test(loader5);
    }
}

类的卸载

  • 当一个类被加载、连接和初始化之后,它的生命周期就开始了。当此类的Class对象不再被引用,即不可触及时,Class对象就会结束生命周期,类在方法区内的数据也会被卸载。
  • 一个类何时结束生命周期,取决于代表它的Class对象何时结束生命周期。
  • 由Java虚拟机自带的类加载器所加载的类,在虚拟机的生命周期中,始终不会被卸载。Java虚拟机本身会始终引用这些加载器,而这些类加载器则会始终引用他们所加载的类的Class对象,因此这些Class对象是可触及的。
  • 由用户自定义的类加载器所加载的类是可以被卸载的。
/**
    自定义类加载器加载类的卸载
    -XX:+TraceClassUnloading
*/
   public static void main(String[] args){
        MyTest16 loader2=new MyTest16("loader2");  
        loader2.path="D:\Eclipse\workspace\HiATMP-DDMS\target\classes\";
        test(loader2);
        loader2=null;
        System.gc();   //让系统去显式执行垃圾回收
        
        输出的两个对象hashcode值不同,因为前面加载的已经被卸载了
        loader2=new MyTest16("loader6"); //  
        test(loader2);
   }

gvisualvm命令 查看当前java进程(gvisualvm在jdk/bin下面)

  • 测试17
/**
    创建自定义加载器,继承ClassLoader
*/
class MyCat{
    public MyCat(){
        System.out.println("MyCat is loaded..."+this.getClass().getClassLoader());
    }
}

class MySample{
    public MySample(){
        System.out.println("MySample is loaded..."+this.getClass().getClassLoader());
        new MyCat();
    }
}

public class MyTest17 extends ClassLoader{
    private String classLoaderName;
    private String path;
    private final String fileExtension=".class";
    
    public MyTest17(String classLoaderName){
        super();        //将系统类当做该类的父加载器
        this.classLoaderName=classLoaderName;
    }
    public MyTest17(ClassLoader parent,String classLoaderName){
        super(parent);      //显式指定该类的父加载器
        this.classLoaderName=classLoaderName;
    }
    
    public void setPath(String path){
        this.path=path;
    }
    @Override
    protect Class<?> findClass(String className){
        System.out.println("calssName="+className);
        className=className.replace(".",File.separator);
        byte[] data=loadClassData(className);
        return defineClass(className,data,0,data.length); //define方法为父类方法
    }//系统加载器就能加载类了,所以可能不通过我们自定义的类加载器。
    
    private byte[] loadClassData(String name){
        InputStream is=null;
        byte[] data=null;
        ByteArrayOutputStream baos=null;
        try{
            is=new FileInputStream(new File(this.path+name+this.fileExtension));
            baos=new ByteArrayOutputStream();
            int ch;
            while(-1!=(ch=is.read())){
                baos.write(ch);
            }
            data=baos.toByteArray();
        }catch(Exception e){
        }finally{
            is.close();
            baos.close();
             return data;
        }
    }
    public static void main(String[] args){
        MyTest17 loader1=new MyTest17("loader1");
        Class<?> clazz=loader1.loadClass("com.hisense.MySample");  
        System.out.println(clazz.hashCode());
        //如果注释掉该行,就并不会实例化MySample对象,不会加载MyCat(可能预先加载)
        Object  object=clazz.newInstance(); //加载和实例化了MySample和MyCat
    }
}

测试17_1

public class MyTest17_1 extends ClassLoader{
    private String classLoaderName;
    private String path;
    private final String fileExtension=".class";
    
    public MyTest17_1(String classLoaderName){
        super();        //将系统类当做该类的父加载器
        this.classLoaderName=classLoaderName;
    }
    public MyTest17_1(ClassLoader parent,String classLoaderName){
        super(parent);      //显式指定该类的父加载器
        this.classLoaderName=classLoaderName;
    }
    
    public void setPath(String path){
        this.path=path;
    }
    @Override
    protect Class<?> findClass(String className){
        System.out.println("calssName="+className);
        className=className.replace(".",File.separator);
        byte[] data=loadClassData(className);
        return defineClass(className,data,0,data.length); //define方法为父类方法
    }
    
    private byte[] loadClassData(String name){
        InputStream is=null;
        byte[] data=null;
        ByteArrayOutputStream baos=null;
        try{
            is=new FileInputStream(new File(this.path+name+this.fileExtension));
            baos=new ByteArrayOutputStream();
            int ch;
            while(-1!=(ch=is.read())){
                baos.write(ch);
            }
            data=baos.toByteArray();
        }catch(Exception e){
        }finally{
            is.close();
            baos.close();
             return data;
        }
    }
    public static void main(String[] args){
        MyTest17_1 loader1=new MyTest17_1("loader1");
        loader1.path="C:\Users\weichengjie\Desktop";
        Class<?> clazz=loader1.loadClass("com.hisense.MySample");  
        System.out.println(clazz.hashCode());
        //MyCat是由加载MySample的加载器去加载的:
        如果只删除classpath下的MyCat,则会报错,NoClassDefFoundError;
        如果只删除calsspath下的MySample,则由自定义加载器加载桌面上的MySample,由系统应用加载器加载MyCatObject  object=clazz.newInstance(); 
    }
    
}

测试17_1_1

//修改MyCat和MySample
class MyCat{
    public MyCat(){
        System.out.println("MyCat is loaded..."+this.getClass().getClassLoader());
        System.out.println("from MyCat: "+MySample.class);
    }
}

class MySample{
    public MySample(){
        System.out.println("MySample is loaded..."+this.getClass().getClassLoader());
        new MyCat();
        System.out.println("from MySample :"+ MyCat.class);
    }
}

public class MyTest17_1 {
        public static void main(String[] args){
        //修改MyCat后,仍然删除classpath下的MySample,留下MyCat,程序报错
        //因为命名空间,父加载器找不到子加载器所加载的类,因此MyCat找不到        
        //MySample。
        MyTest17_1 loader1=new MyTest17_1("loader1");
        loader1.path="C:\Users\weichengjie\Desktop";
        Class<?> clazz=loader1.loadClass("com.hisense.MySample");  
        System.out.println(clazz.hashCode());
        Object  object=clazz.newInstance(); 
    }
}

关于命名空间重要说明:

  1. 子加载器所加载的类能够访问父加载器所加载的类;
  2. 而父加载器所加载的类无法访问子加载器所加载的类。

加载路径:

测试18

public class MyTest18{
    public static void main(String[] args){
        System.out.println(System.getProperty("sun.boot.class.path"));//根加载器路径
        System.out.println(System.getProperty("java.ext.dirs"));//扩展类加载器路径
        System.out.println(System.getProperty("java.class.path"));//应用类加载器路径
    }
}
  • 测试18_1
public class MyTest18_1{
    public static void main(String[] args){
        MyTest16 loader1=new MyTest16("loader1");
        loader1.setPath("C:\Users\weichengjie\Desktop");
        
        //把MyTest1.class文件放入到根类加载器路径中,则由根类加载器加载MyTest1
        Class<?> clazz= loader1.loadClass("MyTest1");
        
        System.out.println("clazz:"+clazz.hashCode());
        System.out.println("class loader:"+clazz.getClassLoader());
        
    }
}
  • 测试19
/**
    各加载器的路径是可以修改的,修改后会导致运行失败,ClassNotFoundExeception
*/
public class MyTest19{
    public static void main(String[] args){
        AESKeyGenerator aesKeyGenerator=new AESKeyGenerator();
        System.out.println(aesKeyGenerator.getClass().getClassLoader());//输出扩展类加载器
        System.out.println(MyTest19.class.getClassLoader());//输出应用类加载器
    }
}
  • 测试20
 class Person{
    private Person person;
    public setPerson(Object object){
        this.person=(Person)object;
    }
 }
 
 public class MyTest20{
    public static void main(String[] args){
        MyTest16 loader1=new MyTest16("loader1");
        MyTest16 loader2=new MyTest16("loader2");
        
        Class<?> clazz1=load1.loadClass("Person");
        Class<?> clazz2=load1.loadClass("Person");
        //clazz1和clazz均由应用类加载器加载的,第二次不会重新加载,结果为true
        System.out.println(clazz1==clazz2);
        
        Object object1=clazz1.getInstance();
        Object object2=clazz2.getInstance();
        
        Method method=clazz1.getMethod("setPerson",Object.class);
        method.invoke(object1,object2);
        
    }
 }
  • 测试21
 public class MyTest21{
    public static void main(String[] args){
        MyTest16 loader1=new MyTest16("loader1");
        MyTest16 loader2=new MyTest16("loader2");
        loader1.setPath("C:\Users\weichengjie\Desktop");
        loader2.setPath("C:\Users\weichengjie\Desktop");
        //删掉classpath下的Person类
        Class<?> clazz1=load1.loadClass("Person");
        Class<?> clazz2=load1.loadClass("Person");
        //clazz1和clazz由loader1和loader2加载,结果为false
        System.out.println(clazz1==clazz2);
        
        Object object1=clazz1.getInstance();
        Object object2=clazz2.getInstance();
        
        Method method=clazz1.getMethod("setPerson",Object.class);
        //此处报错,loader1和loader2所处不用的命名空间
        method.invoke(object1,object2);
    }
 }
  • 测试22
 public class MyTest22{
    static{
        System.out.println("MyTest22 init...");
    }
    public static void main(String[] args){
        System.out.println(MyTest22.class.getClassLoader());
        
        System.out.println(MyTest1.class.getClassLoader());
    }
 }

扩展类加载器只加载jar包,需要把class文件打成jar

  • 测试23
/*
    在运行期,一个Java类是由该类的完全限定名(binary name)和用于加载该类的定义类加载器所共同决定的。如果同样名字(完全相同限定名)是由两个不同的加载器所加载,那么这些类就是不同的,即便.class文件字节码相同,并且从相同的位置加载亦如此。
    在oracle的hotspot,系统属性sun.boot.class.path如果修改错了,则运行会出错:
    Error occurred during initialization of VM
    java/lang/NoClassDeFoundError: java/lang/Object
*/
 public class MyTest23{
    public static void main(String[] args){
        System.out.println(System.getProperty("sun.boot.class.path"));
        System.out.println(System.getProperty("java.ext.dirs"));
        System.out.println(System.getProperty("java.calss.path"));
        
        System.out.println(ClassLoader.class.getClassLoader);
        System.out.println(Launcher.class.getClassLoader);
        
        //下面的系统属性指定系统类加载器,默认是AppClassLoader
        System.out.println(System.getProperty("java.system.class.loader"));
    }
 }
  • 类加载器本身也是类加载器,类加载器又是谁加载的呢??(先有鸡还是现有蛋)
    类加载器是由启动类加载器去加载的,启动类加载器是C++写的,内嵌在JVM中。
  • 内嵌于JVM中的启动类加载器会加载java.lang.ClassLoader以及其他的Java平台类。当JVM启动时,一块特殊的机器码会运行,它会加载扩展类加载器以及系统类加载器,这块特殊的机器码叫做启动类加载器。
  • 启动类加载器并不是java类,其他的加载器都是java类。
  • 启动类加载器是特定于平台的机器指令,它负责开启整个加载过程。

OpenJDK

grepcode:源码分析
Launcher类

Class.forName(String name, boolean initialize, ClassLoader loader);

—利用给定的加载器,返回字符串对应的Class对象。当initialize为true时,会对该类进行初始化(该类之前未初始化),默认为true。
Class.forName(“Foo”) 等同于== Class.forName(“Foo”,true,this.getClass.getClassLoader());

public static Class<?> forName(String name, boolean initialize, ClassLoader loader)
    throws ClassNotFoundException{
    if (loader == null) {
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            ClassLoader ccl = ClassLoader.getCallerClassLoader();  //获取调用者类的ClassLoader
            if (ccl != null) {
                sm.checkPermission(
                    SecurityConstants.GET_CLASSLOADER_PERMISSION);
            }
        }
    }
    return forName0(name, initialize, loader);  //forName0 是一个native方法
}
上下文类加载器

可到了别的地方:https://blog.csdn/hancoder/article/details/118449268

System.out.println(Thread.currentThread().getContextClassLoader());
//
System.out.println(Thread.class.getClassLoader());

线程上下文类加载器的作用:
父ClassLoader可以使用当前线程Thread.currentThread().getContextClassLoader()所制定的ClassLoader加载的类,这就改变了父加载器加载的类无法使用子加载器或是其他没有父子关系的ClassLoader加载的类的情况,即改变了双亲委托模型。

在双亲委托模型下,类加载是由下至上的,即下层的类加载器会委托父加载器进行加载。但是有些接口是Java核心库所提供的的(如JDBC),Java核心库是由启动类记载器去加载的,而这些接口的实现却来自不同的jar包(厂商提供),Java的启动类加载器是不会加载其他来源的jar包,这样传统的双亲委托模型就无法满足要求。通过给当前线程设置上下文类加载器,就可以由设置的上下文类加载器来实现对于接口实现类的加载。

  • 测试25
 public class MyTest25 implement Runable{
    private Thread thread;
    public MyTest25(){
        thread =new Thread(this);
        thread.start();
    }
    
    public void run(){
        ClassLoader classLoader=this.thread.getContextLoader();
        this.setContextLoader(classLoader);
        
        System.out.println("Class:"+classLoader.getClass());
        System.out.println("Parent:"+classLoader.getParent().getClass());
    }
    
    public static void main(String[] args){
        new MyTest25();
    }
 }
 
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
try {
    Thread.currentThread().setContextClassLoader(targetTccl);
    myMethod();//调用了Thread.currentThead().getContextClassLoader();
} finally {
    Thread.currentThread().setContextClassLoader(contextClassLoader);
}
//如果一个类由类加载器A加载,那么这个类的依赖类也是由相同的类加载器加载(如果没被加载过)
//ContextClassLoader的作用就是为了破坏java的类加载委托机制。
//当高层提供了统一的接口让底层实现,同时又要被高层加载(或实例化)底层类时,
//就必须通过线程上下文类加载器来帮助高层的classloader找到并加载类

SPI:Service Provide Interface 服务提供者接口

双亲委托机制在父类加载器加载的类中访问子类加载器加载的类时会出现问题,比如JDBC。JDBC中规定,Driver(数据库驱动)必须向DriverManage注册自己,而DriverManage是BootStrapClassloader加载的,所以DriverManage 中是无法加载到具体的Driver。
此时,服务提供者可以将配置文件放到资源目录的META-INF/services下,高层的接口通过SPI的方式,读取META-INF/services下文件中的类名。
SPI的具体约定为:当服务的提供者提供了服务接口的一种实现之后,在jar包的META-INF/services/目录里同时创建一个以服务接口命名的文件。该文件里就是实现该服务接口的具体实现类。当外部程序装配这个模块的时候,就能通过该jar包META-INF/services里的配置文件找到具体的实现类,并装载实例化,完成模块的注入。基于这一个约定就能很好的找到服务接口的实现类,而不需要在代码里指定。jdk提供服务实现查找的一个工具类:java.util.ServiceLoader

  • 测试27—JDBC案例分析
//跟踪代码
 public class MyTest27{
    public static void main(String[] args){
        //Class.forName("com.mysql.jdbc.Driver");//由于ServiceLoader机制的存在,此行可以注释掉不影响
        Connection connection=DriverManager.getConnection(
  "jdbc:mysql://localhost:3306//mydb","user","password");
        
    }
 }
  • 当调用DriverManager的静态方法是,会造成类的初始化
//类初始化时会运行静态代码块
static {
    loadInitialDrivers();
    println("JDBC DriverManager initialized");
}
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;
    }
    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) {
            }
            return null;
        }
    });
    println("DriverManager.initialize: jdbc.drivers = " + 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);
            Class.forName(aDriver, true, ClassLoader.getSystemClassLoader());
        } catch (Exception ex) {
            println("DriverManager.Initialize: load failed: " + ex);
        }
    }
}

在loadInitialDrivers()方法的代码中,可以发现DriverManager加载Driver的包括两部分:
1.通过System.getProperty(“jdbc.drivers”)进行获取,使用系统类加载器进行加载。但是系统参数"jdbc.drivers"为null,因此不会进行Driver的加载;
2.通过SPI的方式,读取META-INF/services文件夹下的类名,使用当前线程类加载器进行加载。

ServiceLoader

ServiceLoader是由BootStrap Classloader加载的,所以类中引用的其它类也会由BootStrap 尝试去加载。

public static <S> ServiceLoader<S> load(Class<S> service) {
    //ServiceLoader中会尝试用BootStrap 加载具体的Mysql Driver,
    //但ServiceLoader中是不可见的,这样就无法加载。
    //所以取出当前线程的上下文类加载器即appCL,用于后面加载具体的Mysql Driver
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}

public static <S> ServiceLoader<S> load(Class<S> service,ClassLoader loader)
{
    return new ServiceLoader<>(service, loader);
}

private ServiceLoader(Class<S> svc, ClassLoader cl) {
    service = Objects.requireNonNull(svc, "Service interface cannot be null");

    //loader 为ServiceLoader的私有常量,在后面加载具体实现类时会用该加载器进行加载。
    // loader 在构造方法内赋了值,即为上文取到的线程上下文类加载器。
    loader = (cl == null) ? ClassLoader.getSystemClassLoader() : cl
        acc = (System.getSecurityManager() != null) ? AccessController.getContext() : null;
    reload();
}

public void reload() {
    providers.clear();
    lookupIterator = new LazyIterator(service, loader);
}
//LazyIterator是一个ServiceLoader的私有内部类

DriverManager初始化完毕,我们再来看一下mysql提供的Driver类内部的情况

  • com.mysql.jdbc.Driver是java.sql.Driver的具体实现类,在初始化时会向DriverManager注册自己。就是将自身加入到一个名为registeredDrivers的静态成员CopyOnWriteArrayList中。但是实际中,Driver已经在初始化的过程总使用SPI的方式将其进行了注册。
static { 
    try { 
        //会向DriverManager注册自己,注册时会先完成DriverManager的加载和初始化
        DriverManager.registerDriver(new Driver());
    } catch (SQLException var1) { 
        throw new RuntimeException("Can't register driver!");    
    }
}

到此为止,DriverManager类在初始化的过程中,已经使用SPI的方式将mysql提供的Driver加载完毕。

最后再来看看DriverManager调用DriverManager.getConnection( “URL”,“user”,“password”)的内容:

//使用了mysql提供的具体方法获取连接。
public static Connection getConnection(String url,tring user, String password) throws SQLException {
    //将用户名和密码加入到Properties中
    java.util.Properties info = new java.util.Properties();
    if (user != null) {
        info.put("user", user);
    }
    if (password != null) {
        info.put("password", password);
    }
    return (getConnection(url, info, Reflection.getCallerClass()));
}
private static Connection getConnection(String url, java.util.Properties info, Class<?> caller) throws SQLException {
    ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
    synchronized(DriverManager.class) {
        if (callerCL == null) {
            callerCL = Thread.currentThread().getContextClassLoader();
        }
    }

    println("DriverManager.getConnection(\"" + url + "\")");
    SQLException reason = null;

    //遍历注册到registeredDrivers的Driver类
    for(DriverInfo aDriver : registeredDrivers) {
        //检查Driver类的有效性
        if(isDriverAllowed(aDriver.driver, callerCL)) {

            println("    trying " + aDriver.driver.getClass().getName());
            //调用com.myql.jdbc.Driver.connect(...)方法获取连接
            Connection con = aDriver.driver.connect(url, info);
            if (con != null) {
                println("getConnection returning " + 
                        aDriver.driver.getClass().getName());
                return (con);
            }

        } else {
            println("    skipping: " + aDriver.getClass().getName());
        }
    }
}

private static boolean isDriverAllowed(Driver driver, ClassLoader 
                                       classLoader) {
    boolean result = false;
    if(driver != null) {
        Class<?> aClass = null;
        try {
            //传入的classloader为调用getConnection的当前类加载器,从而寻找driver的class对象  
            aClass =  Class.forName(driver.getClass().getName(), true, classLoader);
        } catch (Exception ex) {
            result = false;
        }

        //注意,只有同一个类加载器的Class使用==比较时才会相等,此处就是校验用户注册Driver时该Driver所属的类加载器和调用的类加载器是否是同一个
        //driver.getClass()拿到的就是当初执行Class.forName("com.mysql.jdbc.Driver")时的应用AppClassLoader
        result = ( aClass == driver.getClass() ) ? true : false;
    }
    return result;
}
  • 测试26
public class MyTest26{
    public static void main(String[] args){

        //一旦加入下面此行,将使用ExtClassLoader去加载Driver.class, ExtClassLoader不会去加载classpath,因此无法找到MySql的相关驱动。
        //Thread.getCurrentThread().setContextClassLoader(MyTest26.class.getClassLoader().parent());    

        ServiceLoader服务提供者,加载实现的服务
            ServiceLoader<Driver> loader=ServiceLoader.load(Driver.class);
        Iterator<Driver> iterator=loader.iterator();
        while(iterator.hasNext()){
            Driver driver=iterator.next();
            System.out.println("driver:"+driver.class+
                               ",loader"+driver.class.getClassLoader());
        }
        System.out.println("当前上下文加载器"
                           +Thread.currentThread().getContextClassLoader());
        System.out.println("ServiceLoader的加载器"
                           +ServiceLoader.class.getClassLoader());
    }
}     


完结

附1:另一个字节文件解析

这个文件的字节码部分:https://download.csdn/download/hancoder/12834607

参考

  • 类加载:https://blog.csdn/weixin_38405354/article/details/100042169
  • 字节码:https://blog.csdn/weixin_38405354/article/details/100041386
  • 内存机制:https://blog.csdn/weixin_38405354/article/details/104712746
  • https://note.youdao/ynoteshare1/index.html?id=9ff70d936a330dcd9d42ecb427602975&type=notebook

本文标签: 硅谷笔记最全黑马JVM