全面理解JVM虚拟机

一、JDK体系结构

二、JVM跨平台特性

三、JVM整体结构内存模型

四、JVM内存参数设置

Spring Boot程序的JVM参数设置格式(Tomcat启动直接加在bin目录下catalina.sh文件里):

java 复制代码
java -Xms2048M -Xmx2048M -Xmn1024M -Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -jar microservice-eureka-server.jar

-Xss:每个线程的栈大小

-Xms:设置堆的初始可用大小,默认物理内存的1/64

-Xmx:设置堆的最大可用大小,默认物理内存的1/4

-Xmn:新生代大小

-XX:NewRatio:默认2表示新生代占年老代的1/2,占整个堆内存的1/3。

-XX:SurvivorRatio:默认8表示一个survivor区占用1/8的Eden内存,即1/10的新生代内存。

关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N

-XX:MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。

-XX:MetaspaceSize: 指定元空间触发Fullgc的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M左右,达到该值就会触发full gc进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。这个跟早期jdk版本的-XX:PermSize参数意思不一样,-XX:PermSize代表永久代的初始容量。

由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般我会将这两个值都设置为256M。

注意:

-Xss设置越小count值越小,说明一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数会更多。

五、Class文件解析

文件规范可参考 Oracle 的官方文档。JDK8 的文档地址:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html

1、Class文件结构

class文件本质是一个二进制文件,可以直接用文本工具打开,比如:

中间这一部分就是他的二进制内容,是十六进制的表达。空格隔开的部分代表了 8 个bit,而每一位代表的是 4 个 bit字节,也就是一个十六进制的数字。例如 第一个字母 C 就表示十六进制的 12,二进制是 1100。而所有的class文件,都必须以十六进制的 CAFEBABE 开头,这是 JVM 规范的一部分。

后面的部分比较复杂,需要用一些工具来看。

JDK 自己就提供了一个 javap 指令可以直接来看一些class文件。例如可以用 javap -v ByteCodeInterView.class 查看到这个class文件的详细信息。

​ 也可以在 IDEA 里添加一个 ByteCodeView 插件来更直观的查看一个 ClassFile 的内容。看到的大概内容是这样的:

例如,前面u4表示四个字节是magic魔数,而这个魔数就是不讲道理的 CAFEBABE 。

​ 而后面的两个u2,表示两个字节的版本号。例如我们用 JDK8 看我们之前的class文件,minor_version就是 00 00,major_version就是 00 34。换成二进制就是 52。52.0 这就是 JVM 给 JDK8 分配的版本号。这两个版本号就表示当前这个class文件是由JDK8编译出来的。后续就只能用8以前版本的JVM执行。这就是JDK版本向前兼容的基础。

2、Class文件字节码指令

例如在ByteCodeInterView中的typeTest这个方法,在class文件中就是这样记录的:

这里每一行就是一个字节码指令。

JVM 虚拟机的字节码指令由一个字节长度,代表着某种特定操作含义的数字(称为操作码,OpCode)以及跟随气候的零至多个代表此操作所需要的参数(称为操作数,Operand)构成。

其中操作数,可以是一个具体的参数,也可以是一个指向class文件常量池的符号引用,也可以是一个指向运行时常量池中的一个方法。

比如第 0 行 bipush 10,操作码就是 bipush,操作数就是 10。这个指令就占据了第 0 行和第 1 行两行。而有些操作码,如 astore_1,就只有一个操作码,没有操作数。

Java 虚拟机中的操作码的长度只有一个字节(能表示的数据是0~255),这意味着 JVM 指令集的操作码总数不超过 256 条。

3、字节码中指令对应关系

如果还想更仔细一点的分辨每一样代码都对应哪些指令,在这个工具中还提供了一个LineNumberTable,会告诉你这些指令与代码的对应关系。

注:

起始 PC 就是这些指令的字节码指令的行数,行号则对应 Java 代码中的行数。

实际上,Java 程序在遇到异常时给出的堆栈信息,就是通过这些数据来反馈报错行数的。

4、指令解读

例如:

Integer i1 = 10;

Integer i2 = 10;

System.out.println(i1 == i2);//true

Integer i3 = 128;

Integer i4 = 128;

System.out.println(i3 == i4);//false

面试题:Java 当中的静态方法可以重载吗?

答案:不能。因为在 JVM 中,调用方法提供了几个不同的字节码指令。invokcvirtual 调用对象的虚方法(也就是可重载的这些方法)。invokespecial 根据编译时类型来调⽤实例⽅法,比如静态代码块(通常对应字节码层面的cinit 方法),构造方法(通常对应字节码层面的init方法)。invokestatic 调⽤类(静态)⽅法。invokcinterface 调⽤接⼝⽅法。

静态方法和重载的方法他们的调用指令都是不一样的,那么肯定是无法重载静态方法的。

六、类加载机制

JDK8的类加载机制:

  1. 类缓存:每个类加载器对他加载过的类都有一个缓存。

  2. 双亲委派:向上委托查找,向下委托加载。

  3. 沙箱保护机制:不允许应用程序加载JDK内部的系统类。

1、类加载体系

案例:

java 复制代码
public class LoaderDemo {
    public static String a ="aaa";
    public static void main(String[] args) throws ClassNotFoundException {
        // 父子关系 AppClassLoader <- ExtClassLoader <- BootStrap Classloader
        ClassLoader cl1 = LoaderDemo.class.getClassLoader();
        System.out.println("cl1 > " + cl1);
        System.out.println("parent of cl1 > " + cl1.getParent());
        // BootStrap Classloader由C++开发,是JVM虚拟机的一部分,本身不是JAVA类。
        System.out.println("grant parent of cl1 > " + cl1.getParent().getParent());
        // String,Int等基础类由BootStrap Classloader加载。
        ClassLoader cl2 = String.class.getClassLoader();
        System.out.println("cl2 > " + cl2);
        System.out.println(cl1.loadClass("java.util.List").getClass().getClassLoader());

        // java指令可以通过增加-verbose:class -verbose:gc 参数在启动时打印出类加载情况
       // 这些参数来自于 sun.misc.Launcher 源码
        // BootStrap Classloader,加载java基础类。
        System.out.println("BootStrap ClassLoader加载目录:" + System.getProperty("sun.boot.class.path"));
        // Extention Classloader 加载一些扩展类。 可通过-D java.ext.dirs另行指定目录
        System.out.println("Extention ClassLoader加载目录:" + System.getProperty("java.ext.dirs"));
        // AppClassLoader 加载CLASSPATH,应用下的Jar包。可通过-D java.class.path另行指定目录
        System.out.println("AppClassLoader加载目录:" + System.getProperty("java.class.path"));
    }
}

左侧是JDK中实现的类加载器,通过parent属性形成父子关系。应用中自定义的类加载器的parent都是AppClassLoader

​ 右侧是JDK中的类加载器实现类。通过类继承的机制形成体系。未来我们就可以通过继承相关的类实现自定义类加载器。

JDK8中的类加载器都继承于一个统一的抽象类ClassLoader,类加载的核心也在这个父类中。其中,加载类的核心方法如下:

java 复制代码
//类加载器的核心方法
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 每个类加载起对他加载过的类都有一个缓存,先去缓存中查看有没有加载过
            Class<?> c = findLoadedClass(name);
            if (c == null) {
               //没有加载过,就走双亲委派,找父类加载器进行加载。
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                }

                if (c == null) {
                    long t1 = System.nanoTime();
                   // 父类加载起没有加载过,就自行解析class文件加载。
                    c = findClass(name);
                  
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
           //这一段就是加载过程中的链接Linking部分,分为验证、准备,解析三个部分。
           // 运行时加载类,默认是无法进行链接步骤的。
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

这个方法就是最为核心的双亲委派机制。并且这个方法是protected声明的,这意味着,这个方法是可以被子类覆盖的。所以,双亲委派机制也是可以被打破的。

​ 当一个类加载器要加载一个类时,整体的过程就是通过双亲委派机制向上委托查找,如果没有查找到,就向下委托加载。整个过程整理如下图:

为什么要打破双亲委派呢?想想Tomcat要如何加载webapps目录下的多个不同的应用?

而关于类加载机制的所有有趣的玩法,也都在这个核心方法里。比如class文件加密加载,热加载等。

2、沙箱保护机制

双亲委派机制有一个最大的作用就是要保护JDK内部的核心类不会被应用覆盖。而为了保护JDK内部的核心类,JAVA在双亲委派的基础上,还加了一层保险。就是ClassLoader中的下面这个方法。

java 复制代码
private ProtectionDomain preDefineClass(String name,
                                            ProtectionDomain pd)
    {
        if (!checkName(name))
            throw new NoClassDefFoundError("IllegalName: " + name);
        // 不允许加载核心类
        if ((name != null) && name.startsWith("java.")) {
            throw new SecurityException
                ("Prohibited package name: " +
                 name.substring(0, name.lastIndexOf('.')));
        }
        if (pd == null) {
            pd = defaultDomain;
        }
        if (name != null) checkCerts(name, pd.getCodeSource());
        return pd;
    }

这个方法会用在JAVA在内部定义一个类之前。这种简单粗暴的处理方式,当然是有很多时代的因素。也因此在JDK中,你可以看到很多javax开头的包。这个奇怪的包名也是跟这个沙箱保护机制有关系的。

3、Linked链接过程

在ClassLoader的loadClass方法中,还有一个不起眼的步骤,resolveClass。这是一个native方法。而其实现的过程称为linking-链接。链接过程的实现功能如下图:

其中关于半初始化状态就是JDK在处理一个类的static静态属性时,会先给这个属性分配一个默认值,作用是占住内存。然后等连接过程完成后,在后面的初始化阶段,再将静态属性从默认值修改为指定的初始值。

这里注意,static静态的属性,是属于类的,他是在类初始化过程中维护的。而普通的属性是属于对象的,他是在创建对象的过程中维护的。这两个不要搞混了。

对应到class文件当中,一个是<init>方法,一个是<cinit>方法。

4、类和对象关系

首先:类 Class 在 JVM 中的作用其实就是一个创建对象的模板。也就是说他的作用更多的体现在创建对象的过程当中。而在程序具体执行的过程中,主要是围绕对象在进行,这时候类的作用就不大了。

因此,在 JVM 中,类并不直接保存在最宝贵最核心的堆内存当中,而是挪到了堆内存以外的一部分内存中。这部分内存,在 JDK8 以前被成为永久带PermSpace,而在 JDK8 之后被改为了元空间 MetaSpace。

5、关于双亲委派机制的思考

5.1、我们可以通过打破双亲委派绕过JDK的沙箱保护机制吗?

​ 显然不能。因为JDK内部的三个类加载器示例的实现是改不了的。只要这三个类加载器的加载改不了,那么JDK中那些核心的类就还是安全的。

​ 其实,这个问题也可以延伸到JDK8往后的版本当中。从JDK9开始,JDK中引入了模块化机制,而内部的类加载器实现也随之做了翻天覆地的改变。每个类加载器不再是单独负责一个工作目录,而是改为分工负责一部分的模块。但是,对于自定义类加载器,JDK还是保留了原有的双亲委派机制。在之后带大家分析JDK17的类加载机制时会看到,虽然JDK17内部的加载机制发生了变化,但是我们这些案例,几乎都可以平滑的转移过去。

还是要注意:是几乎,而不是完全。因为模块化影响的是整个方方面面。但是核心的加载流程,是没有问题的。

5.2、在真实项目中,有什么样的业务场景需要打破双亲委派呢?

​ 双亲委派机制是非常基础的一个底层体系,很多重要框架都需要进行定制。

​ 例如Tomcat的类加载体系如下:

tomcat的几个主要类加载器:

  • commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;

  • catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;

  • sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;

  • WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见,比如加载war包里相关的类,每个war包应用都有自己的WebappClassLoader,实现相互隔离,比如不同war包应用引入了不同的spring版本,这样实现就能加载各自的spring版本;

  • Jsp类加载器:针对每个JSP页面创建一个加载器。这个加载器比较轻量级,所以Tomcat还实现了热加载,也就是JSP只要修改了,就创建一个新的加载器,从而实现了JSP页面的热更新。

七、对象创建流程

对象创建的主要流程:

1.类加载检查

虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

new指令对应到语言层面上讲是,new关键词、对象克隆、对象序列化等。

2.分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把 一块确定大小的内存从Java堆中划分出来。

这个步骤有两个问题:

1.如何划分内存

2.在并发情况下, 可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况

划分内存的方法:

  • "指针碰撞"(Bump the Pointer)(默认用指针碰撞)

如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。

  • "空闲列表"(Free List)

如果Java堆中的内存并不是规整的,已使用的内存和空 闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记 录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录

解决并发问题的方法:

  • CAS(compare and swap)

虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。

  • 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)

把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。通过-XX:+/-UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启-XX:+UseTLAB),-XX:TLABSize 指定TLAB大小。

对象内存分配流程图

2.1、对象栈上分配

通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。

为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

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

逃逸分析参数:-XX:+DoEscapeAnalysis,JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)

**标量替换:**通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启。

**标量与聚合量:**标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。

栈上分配示例:

java 复制代码
/**
 * 栈上分配,标量替换
 * 代码调用了1亿次alloc(),如果是分配到堆上,大概需要1GB以上堆空间,如果堆空间小于该值,必然会触发GC。
 * 
 * 使用如下参数不会发生GC
 * -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
 * 使用如下参数都会发生大量GC
 * -Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
 * -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
 */
public class AllotOnStack {

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }

    private static void alloc() {
        User user = new User();
        user.setId(1);
        user.setName("zhuge");
    }
}

结论:栈上分配依赖于逃逸分析和标量替换

2.2、对象在Eden区分配

大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

Minor GC和Full GC 的区别:

  • Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。
  • Major GC/Full GC:一般会回收老年代 ,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢10倍以上。

Eden与Survivor区默认8:1:1

大量的对象被分配在eden区,eden区满了后会触发minor gc,可能会有99%以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到为空的那块survivor区,下一次eden区满了后又会触发minor gc,把eden区和survivor区垃圾对象回收,把剩余存活的对象一次性挪动到另外一块为空的survivor区,因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1的比例是很合适的,让eden区尽量的大,survivor区够用即可。

JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy。

2.3、大对象直接进入老年代

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

JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。

比如设置JVM参数:-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC ,再执行下上面的第一个程序会发现大对象直接进了老年代

为什么要这样设置:为了避免为大对象分配内存时的复制操作而降低效率。

2.4、长期存活的对象将进入老年代

由于虚拟机采用了分代收集的思想来管理内存,内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。

对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

3.初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

4.设置对象头

初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data)和对齐填充(Padding)。

HotSpot虚拟机的对象头包括两部分信息,一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等;另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

32位对象头

64位对象头

对象头在hotspot的C++源码markOop.hpp文件里的注释如下:

cpp 复制代码
// Bit-format of an object header (most significant first, big endian layout below):
//
//  32 bits:
//  --------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

5.执行《init》方法

执行方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。

5.1、解释执行与编译执行

​ JVM 中有两种执行的方式:

  • 解释执行就相当于是同声传译。JVM 接收一条指令,就将这条指令翻译成机器指令执行。
  • 编译执行就相当于是提前翻译。JAVA的基本思想就是维护一个缓存,CodeCache,将那些字节码指令,提前编译出来,放到缓存里。到执行的时候,直接从缓存中查出来就好了。。编译执行也就是传说中的 JIT 。

大部分情况下,使用编译执行的方式显然比解释执行更快,减少了翻译机器指令的性能消耗。而我们常用的 HotSpot 虚拟机,最为核心的实现机制就是这个 HotSpot 热点。他会搜集用户代码中执行最频繁的热点代码,形成CodeCache,放到元空间中,后续再执行就不用编译,直接执行就可以了。

但是编译执行起始也有一个问题,那就是程序预热会比较慢。毕竟作为虚拟机,你不可能提前预知到程序员要写一些什么稀奇古怪的代码,也就不可能把所有代码都提前编译成模板。而将执行频率并不高的代码也编译保存下来,也是得不偿失的。

所以,现在JDK 默认采用的就是一种混合执行的方式。他会自己检测采用那种方式执行更快。虽然你可以干预 JDK 的执行方式,但是在绝大部分情况下,都是不需要进行干预的。

5.2、编译执行时的代码优化

热点代码会触发 JIT 实时编译,而JIT 编译运用了一些经典的编译优化技术来实现代码的优化,可以智能地编译出运行时的最优性能代码。

​ HotSpot虚拟机中的内置编译器:"客户端编译器"(Client Compiler)和"服务端编译器"(Server Compiler),或者简称为C1编译器和C2编译器。

  • C1 会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度。启动快,占用内存小,执行效率没有server快。默认情况下不进行动态编译,适用于桌面应用程序。
  • C2 进行耗时较长的优化,以及激进优化,但优化的代码执行效率更高。启动慢,占用内存多,执行效率高,适用于服务器端应用。 默认情况下就是使用的 C2 编译器。并且,绝大部分情况下也不建议特意去使用 C1。
5.2.1、逃逸分析Escape Analysis

当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。

​ 左侧的代码中,t对象,不会被外部引用,只会在方法中使用,所以不会发生逃逸。而右侧的代码中,t对象就很明显被其他方法使用了,这就会产生逃逸。

JDK8 中默认开启了逃逸分析,可以添加参数 -XX:-DoEscapeAnalysis 主动关闭逃逸分析。

​ 如果能证明一个对象不会逃逸到方法或线程之外,那么 JIT 就可以为这个对象实例采取后续一系列的优化措施。

5.2.2、标量替换

若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。

如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。

假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。

将对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步的优化手段创建条件。

标量替换对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。

JDK8 中默认开启了标量替换,可以通过添加参数 -XX:-EliminateAllocations 主动关闭标量替换。

5.2.3、栈上分配

正常情况下,JVM 中所有对象都应该创建在堆上,并由 GC 线程进行回收。

如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。

在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。

栈上分配可以支持方法逃逸,但不能支持线程逃逸。

5.2.4、锁消除 lock elision

这也是经过逃逸分析后可以直接进行的优化措施。

​ 这个优化措施主要是针对 synchronized 关键字。当 JVM 检测到一个锁的代码不存在多线程竞争时,会对这个对象的锁进行锁消除。

多线程并发资源竞争是一个很复杂的场景,所以通常要检测是否存在多线程竞争是非常麻烦的。

但是有一种情况很简单,如果一个方法没有发生逃逸,那么他内部的锁都是不存在竞争的。

5.3、热点代码识别

识别热点代码的行为称为"热点探测 "(Hot Spot Code Detection)。热点探测有很多种实现思路,而在 HotSpot 虚拟机中采用的是一种基于计数器的热点探测方法。HotSpot 为每个方法准备了两类计数器: 方法调用计数器 (Invocation Counter)和回边计数器(Back Edge Counter)。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。

5.3.1、方法调用计数器

方法调用计数器,就是用于统计方法被调用的次数,默认阈值10000次。执行流程:

方法计数器以方法为维度,不够精细。所以,要更精细的识别热点代码,还需要配合回边计数器

5.3.2、回边计数器

回边计数器,它的作用是统计一个方法中循环体代码执行的次数,默认阈值10700。整体流程:

5.3.3、方法内联InLine

方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。这样就可以减少频繁创建栈帧的性能开销。

当然,发生方法内联的前提是要让这个方法循环足够的次数,成为热点代码。

5.4、静态执行与动态执行

​ 静态执行指在 Class 文件编译过程中就已经确定了执行方法。 动态执行指需要在运行期间才能确定调用哪个方法。比如多个重载的方法,需要根据传入类型确定调用哪个方法。

​ 动态执行更多的是关联到invokedynamic指令。在JVM的语言体系中,以Scala为代表的函数式的编程方式会越来越重要,到时候动态执行也会随之变得更为重要。

6、对象大小

对象大小可以用jol-core包查看,引入依赖

java 复制代码
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>
java 复制代码
import org.openjdk.jol.info.ClassLayout;

/**
 * 计算对象大小
 */
public class JOLSample {

    public static void main(String[] args) {
        ClassLayout layout = ClassLayout.parseInstance(new Object());
        System.out.println(layout.toPrintable());

        System.out.println();
        ClassLayout layout1 = ClassLayout.parseInstance(new int[]{});
        System.out.println(layout1.toPrintable());

        System.out.println();
        ClassLayout layout2 = ClassLayout.parseInstance(new A());
        System.out.println(layout2.toPrintable());
    }

    // -XX:+UseCompressedOops           默认开启的压缩所有指针
    // -XX:+UseCompressedClassPointers  默认开启的压缩对象头里的类型指针Klass Pointer
    // Oops : Ordinary Object Pointers
    public static class A {
                       //8B mark word
                       //4B Klass Pointer   如果关闭压缩-XX:-UseCompressedClassPointers或-XX:-UseCompressedOops,则占用8B
        int id;        //4B
        String name;   //4B  如果关闭压缩-XX:-UseCompressedOops,则占用8B
        byte b;        //1B 
        Object o;      //4B  如果关闭压缩-XX:-UseCompressedOops,则占用8B
    }
}


运行结果:
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)    //mark word
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)    //mark word     
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)    //Klass Pointer
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total


[I object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           6d 01 00 f8 (01101101 00000001 00000000 11111000) (-134217363)
     12     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
     16     0    int [I.<elements>                             N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total


com.tuling.jvm.JOLSample$A object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0     4                    (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4                    (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4                    (object header)                           61 cc 00 f8 (01100001 11001100 00000000 11111000) (-134165407)
     12     4                int A.id                                      0
     16     1               byte A.b                                       0
     17     3                    (alignment/padding gap)                  
     20     4   java.lang.String A.name                                    null
     24     4   java.lang.Object A.o                                       null
     28     4                    (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total

7、指针压缩

  • jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩
  • jvm配置参数:UseCompressedOops,compressed--压缩、oop(ordinary object pointer)--对象指针
  • 启用指针压缩:-XX:+UseCompressedOops(默认开启),禁止指针压缩:-XX:-UseCompressedOops

进行指针压缩意义:

1、在64位平台的HotSpot中使用32位指针(实际存储用64位),内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力

2、为了减少64位平台下内存的消耗,启用指针压缩功能

3、在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的存入堆内存时压缩编码、取出到cpu寄存器后解码方式进行优化(对象指针在堆中是32位,在寄存器中是35位,2的35次方=32G),使得jvm只用32位地址就可以支持更大的内存配置(小于等于32G)

4、堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间

5、堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好

关于对齐填充:对于大部分处理器,对象以8字节整数倍来对齐填充都是最高效的存取方式。

八、GC 垃圾回收

1、整体内存模型

内存模型布局:

其中堆区是 JVM 核心的存放对象的内存区域。他的大小可以由参数 -Xms(初始堆内存大小),-Xmx(最大堆内存)参数指令。从这两个参数可以看到,堆内存是可以扩展的。

如果初始内存不够,JVM 会扩大堆内存。但是如果内存扩展到了最大堆内存时还不够。这时就无法继续扩展了,而是会抛出 OOM 异常。这两个参数在生产环境中最好设置成一样,减少内存扩展时的性能消耗。

​ 而GC垃圾回收器,就是要对这些内存空间进行及时回收,从而让这些内存可以重复利用。

2、分代收集模型

JDK8默认的垃圾回收器Parallel Scavenge,​ 其整体的工作机制如下图:

​ JAVA做过统计,80%的对象都是"朝生夕死"。这些对象,被集中放在了一块比较小的内存空间当中,快速创建,快速回收,这块内存区域就是年轻代。在年轻代会非常频繁的进行垃圾回收,称为YoungGC。而年轻代又会被进一步划分为一个eden_space和两个survivor。这三个区域的大小比例默认是 8:1:1。

​ 另外少部分需要长期使用的对象,被放到另一块竞争没有那么激烈的对象,则被放到另外一块比较大的内存空间当中,长期保持,这块内存就是老年代。在老年代,垃圾回收的频率则会相对比较低,只有空间不够时才进行,称为OldGC。

​ 年轻代与老年代默认的大小比例是 1:2。

​ 常见的分代收集模型中,对象会优先在eden区创建,经过一次YoungGC后,如果没有被回收,就会被移动到一个survivor区。接下来,下一次YoungGC时,又会被移动到另一块Survivor区。每移动一次,记录一个分代年龄。直到分代年龄太大了(默认是16),就会被移动到老年代。到老年代后,对象就不再记录分代年龄了,在老年代安安静静的用到退休。

​ 通过分代收集机制,JVM可以对不同的对象采取不同的回收策略,从而提高垃圾回收的效率。

3、垃圾回收器类别

java 从诞生到现在最新的 JDK21 版本,总共就产生了以下十个垃圾回收器

​ 其中,左边的都是分代算法。也就是将内存划分为年轻代和老年代进行管理。而有虚线的部分表示可以协同进行工作。JDK8默认就是使用的Parallel Scavenge和Parallel Old的组合。也就是在arthas的dashboard中看到的ps。

​ 右侧的是不分代算法。也就是不再将内存严格划分位年轻代和老年代。JDK9 开始默认使用 G1。而 ZGC是目前最先进的垃圾回收器。shennandoah则是OpenJDK 中引入的新一代垃圾回收器,与 ZGC 是竞品关系。Epsilon是一个测试用的垃圾回收器,根本不干活。

4、常用命令

-XX:+PrintGC: 打印GC信息 类似于-verbose:gc

​ -XX:+PrintGCDetails: 打印GC详细信息,这里主要是用来观察FGC的频率以及内存清理效率。

​ -XX:+PrintGCTimeStamps 配合 -XX:+PrintGC使用。在 GC 中打印时间戳。

​ -XX:PrintHeapAtGC: 打印GC前后的堆栈信息

​ -Xloggc:filename : GC日志打印文件。

不同 JDK 版本会有不同的参数。 比如 JDK9 中,就不用分这么多参数了,可以统一使用-X-log:gc* 通配符打印所有的 GC 日志。

5、对象动态年龄判断

当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。

对象动态年龄判断机制一般是在minor gc之后触发的。

6、老年代空间分配担保机制

1、年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间

2、如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象)

3、就会看一个"-XX:-HandlePromotionFailure"(jdk1.8默认就设置了)的参数是否设置了

4、如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小

5、如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会发生"OOM"

6、如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生"OOM"

7、对象内存回收

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。

7.1、引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;

当引用失效,计数器就减1;

任何时候计数器为0的对象就是不可能再被使用的。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。

所谓对象之间的相互引用问题,如下面代码所示:除了对象objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知 GC 回收器回收他们。

java 复制代码
public class ReferenceCountingGc {
   Object instance = null;

   public static void main(String[] args) {
      ReferenceCountingGc objA = new ReferenceCountingGc();
      ReferenceCountingGc objB = new ReferenceCountingGc();
      objA.instance = objB;
      objB.instance = objA;
      objA = null;
      objB = null;
   }
}

7.2、可达性分析算法

将"GC Roots" 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象

GC Roots根节点:线程栈的本地变量、静态变量、本地方法栈的变量等等

7.3、常见引用类型

java的引用类型一般分为四种:强引用、软引用、弱引用、虚引用

**强引用:**普通的变量引用

java 复制代码
public static User user = new User();

**软引用:**将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC做完后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉。软引用可用来实现内存敏感的高速缓存。

java 复制代码
public static SoftReference<User> user = new SoftReference<User>(new User());

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

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

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

**弱引用:**将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用

java 复制代码
public static WeakReference<User> user = new WeakReference<User>(new User());

**虚引用:**虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用

7.4、finalize()方法最终判定对象是否存活

即使在可达性分析算法中不可达的对象,也并非是"非死不可"的,这时候它们暂时处于"缓刑"阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。

标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。

  1. 第一次标记并进行一次筛选。

筛选的条件是此对象是否有必要执行finalize()方法。

当对象没有覆盖finalize方法,对象将直接被回收。

  1. 第二次标记

如果这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出"即将回收"的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。

注意:一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会就一次。

8、如何判断一个类是无用的类

方法区主要回收的是无用的类,类需要同时满足下面3个条件才能算是 "无用的类" :

  • 该类所有的对象实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

九、日均百万级订单交易系统如何设置JVM参数

相关推荐
2401_887724502 小时前
CSS如何设置文字溢出显示省略号_利用text-overflowellipsis
jvm·数据库·python
m0_747854522 小时前
golang如何实现应用启动耗时分析_golang应用启动耗时分析实现思路
jvm·数据库·python
解救女汉子2 小时前
如何截断SQL小数位数_使用TRUNCATE函数控制精度
jvm·数据库·python
2301_803875613 小时前
如何用 objectStore.get 根据主键 ID 获取数据库单条数据
jvm·数据库·python
weixin_458580123 小时前
如何修改AWR保留时间_将默认8天保留期延长至30天的设置
jvm·数据库·python
丶小鱼丶3 小时前
Java虚拟机【JVM】
java·jvm
qq_654366983 小时前
C#怎么实现OAuth2.0授权_C#如何对接第三方快捷登录【核心】
jvm·数据库·python
justjinji3 小时前
如何用 CSS 变量配合 JS setProperty 实现动态换肤功能
jvm·数据库·python
2301_803875613 小时前
C#怎么使用TopLevel顶级语句 C#顶级语句怎么写如何省略Main方法简化控制台程序【语法】
jvm·数据库·python