JVM上篇架构模型,内存与垃圾回收篇

JVM架构模型

基于栈式架构的特点

设计和实现更简单,适用于资源首先的系统

避开了寄存器的分配难题 使用零地址指令方式的分配

指令流中的指令大部分式零地址指令,其执行过程依赖于操作站。指令集更小编译器更容易实现

基于寄存器架构的特点

指令集架构完全依赖硬件,可移植性差

性能优秀,执行高效

将java代码反编译,使用命令

javap -v [文件名]

下面反编译的文件就是基于栈架构的字节码文件

JVM的生命周期

启动

java虚拟机的启动时通过引导类加载器,加载了一个初始类init class来完成的,这个类是由虚拟机的具体实现而指定的

执行

使用jps命令,查看虚拟机的执行状态

退出

当程序发生异常或者主动删除进程就会退出

解释器和编译器

解释器需要编译器进行代码优化/代码缓存

编译器需要解释器加快启动速度,和响应速度

解释器和编译器协同工作,在最优化的程序响应时间和最佳执行性能之前取得平衡

默认使用hotSpot虚拟机

内存结构概述

1. 先通过类加载器对类进行加载,之后在进行连接,最后在进行初始化

2. 从左到右分别是方法区、堆、栈帧、线程PC计数器、本地方法栈

3. 执行引擎从左到右依次是  解释器 JIT编译器 垃圾回收器

4. 本地方法接口和本地方法库

附上全图

类加载器

加载阶段

ClassLoader 只负责class文件的加载。至于它是否能运行,则由执行引擎来判断

验证阶段

确保class文件中的字节流符合虚拟机的要求

比如所有合法的class文件的开头都是CA FE BABE

语义检查等等

准备阶段

在准备阶段 为类分配内存

设置变量初始值

解析阶段

加载其他类以及将常量池内的符号引用转换为直接引用的过程

初始化过程

  • init是instance实例构造器,对非静态变量解析初始化
  • clinit是class类构造器对静态变量,静态代码块进行初始化。


//获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

//获取其上层:扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d

//获取其上层:获取不到引导类加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader);//null

//对于用户自定义类来说:默认使用系统类加载器进行加载
ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

//String类使用引导类加载器进行加载的。---> Java的核心类库都是使用引导类加载器进行加载的。
ClassLoader classLoader1 = String.class.getClassLoader();
System.out.println(classLoader1);//null

虚拟机栈

虚拟机栈(Virtual Machine Stack)是Java虚拟机(JVM)运行时数据区域之一,用于存储方法执行期间的局部变量、操作数栈、方法出口等信息。

当一个方法被调用时,Java虚拟机会为该方法创建一个新的栈帧(Stack Frame),并将该栈帧推入虚拟机栈顶。该栈帧包含了该方法的局部变量表、操作数栈、方法返回地址等信息。

方法执行期间,JVM会不断地将操作数栈中的数据压入、弹出,将局部变量表中的数据读取、修改。当该方法执行完毕时,JVM会将该栈帧弹出,并将程序计数器设置为方法返回地址。

虚拟机栈具有固定的深度,如果该栈已满,JVM会抛出StackOverflowError异常;如果JVM无法再为新方法分配栈帧,JVM会抛出OutOfMemoryError异常。

栈帧

每执行一个方法时,就会入栈一个栈帧

每执行完一个方法时,就会出栈一个栈帧

通过-Xss256K可以设置栈的大小为256k

-Xss256K

栈帧中存储什么

每个线程都有自己的栈,栈中的数据都是以栈帧的格式进行存储

线程上正在执行的每个方法都各自对应一个栈帧

当前栈帧执行结束的话会返回当前结果

局部变量表

每定义一个局部变量都会分配一个slot,局部变量被回收时slot可以重用

在局部变量表中除了long和double占用32字节,其他的都会被转换成int类型占用16字节(包括引用类型)

操作数栈

操作数栈,主要是保存临时的运算结果,也作为计算过程中变量临时的存储空间

bipush命令就是入操作数栈

iload命令就是出操作数栈

java虚拟机的解释引擎是基于栈的执行引擎,这句话中的栈指的就是操作数栈

操作数栈和局部变量表一起在开始就确定了内存大小

和局部变量表一样,除了long和double 占用32字节栈深,其他的数据类型都占用16字节栈深

动态链接

栈帧内部引用的方法会存储一个指向运行时常量池的引用 包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)

invokevirtual 就是方法引用 引用的常量池的地址是#3

也就是Fieldref ,而常量池#3 又指向了#25,#26 如此循环往复就形成了 复杂的嵌套方法调用

方法的绑定机制

前两个主要指的是对象,后两个主要指的是方法

静态绑定

对象在声明时候的类型,是在编译时期确定的。

动态绑定

目标所指向的对象,是在运行期决定的。对象的动态类型可以更改,但是静态类型无法更改。

早期绑定

构造器方法就是早期绑定

编译器编译阶段就可以确定,调用的方法具体是什么内容

晚期绑定

接口调用,和向上转型就是晚期绑定

编译器编译阶段不能确定,调用的方法具体是什么内容

多态的使用前提:具备类的集成关系,有方法的重写

虚方法与非虚方法

调用的方法确定不确定,决定了 调用的方法是虚方法还是非虚方法

JVM调用方法的指令

前两个是在解析阶段就明确知道的方法 非虚方法

剩下三个是在运行期才可以知道的方法 虚方法

注意下边这个虽然使用的是invokevirtual但是因为方法上存在final关键字,所以仍然叫做非虚方法

使用lambda表达式

总结

可以被重写的方法都是虚方法

动态和静态语言

静态语言参照自身类型

动态语言参照值的类型信息

动态语言JAVA

String s1 = "1";

确定s1的类型依靠的是String,而不是1

静态语言JS

var s1 = 123

确定s1的类型依靠的是123,而不是var

虚方法表

方法重写的本质,如何确定应该调用哪个方法,从虚方法表中进行查询

因为虚方法不确定运行时的调用状态所以,JVM为了性能优化 引入了虚方法表

解析环节会确定虚方法表

方法返回地址

正常退出

存放PC寄存器的值

方法退出后栈帧弹出,PC寄存器需要移动到上层调用者的下一个需要执行的方法上

异常退出

返回地址通过异常表来进行确定

不会给上层调用者任何返回值

如果没进行处理就跳转到8 如果进行处理就跳转到11

一些附加信息

一些为调试提供支持的信息

虚拟机栈面试题

举例栈溢出的情况?

stackoverflow递归调用,没有出口

调整大小能保证不溢出吗?

不能保证,递归调用也会消耗完所有的栈空间

分配的栈越大越好吗?

不是,栈空间合理即可,需要保证栈帧存储的空间,同时又保证内存不会浪费

垃圾回收机制是否会涉及虚拟机栈?

不会设计虚拟机栈

虚拟机栈不存在GC 存在error

只通过入栈出栈的方式进行垃圾回收

方法定义的局部变量表是否线程安全?

安全情况:

变量不出不入方法,只在方法内部操作

每个线程都单独有用一个虚拟机栈,他们并不需要共享,而每一个虚拟机栈中会有栈帧,栈帧里面又局部变量表,所以对于每个线程,局部变量表都是隔离的

不安全情况:

在方法外声明

这个时候变量就不只存在栈帧里面了,也存在于其他调用了该方法的线程里面

练习代码

public class StringBuilderTest {

    int num = 10;

    //s1的声明方式是线程安全的
    public static void method1(){
        //StringBuilder:线程不安全
        StringBuilder s1 = new StringBuilder();
        s1.append("a");
        s1.append("b");
        //...
    }
    //sBuilder的操作过程:是线程不安全的
    public static void method2(StringBuilder sBuilder){
        sBuilder.append("a");
        sBuilder.append("b");
        //...
    }
    //s1的操作:是线程不安全的
    public static StringBuilder method3(){
        StringBuilder s1 = new StringBuilder();
        s1.append("a");
        s1.append("b");
        return s1;
    }
    //s1的操作:是线程安全的
    public static String method4(){
        StringBuilder s1 = new StringBuilder();
        s1.append("a");
        s1.append("b");
        return s1.toString();
    }

    public static void main(String[] args) {
        StringBuilder s = new StringBuilder();


        new Thread(() -> {
            s.append("a");
            s.append("b");
        }).start();

        method2(s);

    }

}

本地方法理解

就是不是用java语言实现的,在java语言中就是C++来实现的

例如Object类的getClass方法

一个jvm实例只有一个堆和一个方法区,多个线程共享这个堆和方法区

GC会影响用户进程,不能频繁的进行GC操作

JIT编译器优化

逃逸分析

JIT编译器在编译期间,如果发现对象没有逃逸出方法的话就可以进行栈上分配,栈上分配完成之后,垃圾就不需要进行CG,而是直接通过栈弹出,节省了系统资源

只有在服务器端才会开启

同步省略

通过同步块所使用的锁对象是不是只能被一个线程访问到,如果只可以被一个线程访问到,就取消同步代码块

标量替换

通过逃逸分析,将类肢解为最小单元

是指一个无法再分解为更小的数据。

比如new Point

在java虚拟机里面可以分解为int x和int y

这样就省去了GC

对象实例化方式

1. new对象

2. class类的 newInstance方法

3. Constructor的newInstance方法

4. 使用clone

5. 使用反序列化

过程

内存布局

对齐填充保证所有的类 都是8字节的倍数

这张图得仔细看看 

执行引擎

解释器先解释代码,在对代码进行计数,如果在server模式下计数到达20000,就通过JIT编译器对代码进行缓存直接保存机器码。

String的不可变性

public class StringTest5 {
    @Test
    public void test1(){
        String s1 = "a" + "b" + "c";//编译期优化:等同于"abc"
        String s2 = "abc"; //"abc"一定是放在字符串常量池中,将此地址赋给s2
        /*
         * 最终.java编译成.class,再执行.class
         * String s1 = "abc";
         * String s2 = "abc"
         */
        System.out.println(s1 == s2); //true
        System.out.println(s1.equals(s2)); //true
    }

    @Test
    public void test2(){
        String s1 = "javaEE";
        String s2 = "hadoop";

        String s3 = "javaEEhadoop";
        String s4 = "javaEE" + "hadoop";//编译期优化
        //如果拼接符号的前后出现了变量,则相当于在堆空间中new String(),具体的内容为拼接的结果:javaEEhadoop
        String s5 = s1 + "hadoop";
        String s6 = "javaEE" + s2;
        String s7 = s1 + s2;

        System.out.println(s3 == s4);//true
        System.out.println(s3 == s5);//false
        System.out.println(s3 == s6);//false
        System.out.println(s3 == s7);//false
        System.out.println(s5 == s6);//false
        System.out.println(s5 == s7);//false
        System.out.println(s6 == s7);//false
        //intern():判断字符串常量池中是否存在javaEEhadoop值,如果存在,则返回常量池中javaEEhadoop的地址;
        //如果字符串常量池中不存在javaEEhadoop,则在常量池中加载一份javaEEhadoop,并返回次对象的地址。
        String s8 = s6.intern();
        System.out.println(s3 == s8);//true
    }

    @Test
    public void test3(){
        String s1 = "a";
        String s2 = "b";
        String s3 = "ab";
        /*
        如下的s1 + s2 的执行细节:(变量s是我临时定义的)
        ① StringBuilder s = new StringBuilder();
        ② s.append("a")
        ③ s.append("b")
        ④ s.toString()  --> 约等于 new String("ab")

        补充:在jdk5.0之后使用的是StringBuilder,在jdk5.0之前使用的是StringBuffer
         */
        String s4 = s1 + s2;//
        System.out.println(s3 == s4);//false
    }
    /*
    1. 字符串拼接操作不一定使用的是StringBuilder!
       如果拼接符号左右两边都是字符串常量或常量引用,则仍然使用编译期优化,即非StringBuilder的方式。
    2. 针对于final修饰类、方法、基本数据类型、引用数据类型的量的结构时,能使用上final的时候建议使用上。
     */
    @Test
    public void test4(){
        final String s1 = "a";
        final String s2 = "b";
        String s3 = "ab";
        String s4 = s1 + s2;
        System.out.println(s3 == s4);//true
    }
    //练习:
    @Test
    public void test5(){
        String s1 = "javaEEhadoop";
        String s2 = "javaEE";
        String s3 = s2 + "hadoop";
        System.out.println(s1 == s3);//false

        final String s4 = "javaEE";//s4:常量
        String s5 = s4 + "hadoop";
        System.out.println(s1 == s5);//true

    }

    /*
    体会执行效率:通过StringBuilder的append()的方式添加字符串的效率要远高于使用String的字符串拼接方式!
    详情:① StringBuilder的append()的方式:自始至终中只创建过一个StringBuilder的对象
          使用String的字符串拼接方式:创建过多个StringBuilder和String的对象
         ② 使用String的字符串拼接方式:内存中由于创建了较多的StringBuilder和String的对象,内存占用更大;如果进行GC,需要花费额外的时间。

     改进的空间:在实际开发中,如果基本确定要前前后后添加的字符串长度不高于某个限定值highLevel的情况下,建议使用构造器实例化:
               StringBuilder s = new StringBuilder(highLevel);//new char[highLevel]
     */
    @Test
    public void test6(){

        long start = System.currentTimeMillis();

//        method1(100000);//4014
        method2(100000);//7

        long end = System.currentTimeMillis();

        System.out.println("花费的时间为:" + (end - start));
    }

    public void method1(int highLevel){
        String src = "";
        for(int i = 0;i < highLevel;i++){
            src = src + "a";//每次循环都会创建一个StringBuilder、String
        }
//        System.out.println(src);

    }

    public void method2(int highLevel){
        //只需要创建一个StringBuilder
        StringBuilder src = new StringBuilder();
        for (int i = 0; i < highLevel; i++) {
            src.append("a");
        }
//        System.out.println(src);
    }
}

s1 = "abc"

s2 = new String("123")

s1保存在字符串常量池中

s2保存在堆中

StringBuilder

StringBuilder和String有本质区别

StringBuilder的char数组保存在堆中,而String的数组保存在常量池中

字符串常量的拼接只要出现了变量 底层就会调用StringBuilder 的toString方法,新建了一个String

new String创建了2个对象

1. String的实例 在堆中

2. char数组 在常量池

new String+new String 创建了7个对象

1. 两个String对象

2. 两个在堆中的对象

3. 一个StringBuilder对象

4. StringBuilder toString方法返回的String对象(常量池记录了一个引用,而不是一个串)

GC垃圾回收

什么是垃圾?

垃圾是指没有指针引用的对象

如果不对垃圾进行回收,垃圾会一直存在直到发生OOM错误

在早期的C/C++时代,垃圾回收基本上是手工进行的。开发人员通过new新建,delete删除

这种方式可以灵活控制内存释放的时间,但是管理指针很麻烦

内存泄漏指的就是 分配了一个内存空间,但是没有去回收

GC的作用区域

方法区

存在OOM的地方都会存在GC

垃圾标记阶段算法

判断对象存活的方法:引用计数法 可达性分析法

引用计数法

有一个引用指向了这个实例,就让这个实例的引用数+1,记录被引用的次数,当引用次数变为0的时候进行回收

缺点:无法解决循环引用的问题

所以java不使用这个垃圾回收算法

解决方法:弱引用

可达性分析算法

GcRoots

可达性分析是以跟对象集合GC Roots为起始点

什么样的指针是GC Roots呢?

一个指针指向了堆中的对象,但是又不在堆中,这样的指针就是GC Roots

比如方法的局部变量

比如String Table的串

除了这些之外

还有临时加入GC Roots的引用

比如 分代收集和局部回收

标记清除算法

标记被引用的对象

删除没有被引用的对象

缺点:会造成内存碎片、发生STW

复制算法

开辟两个一摸一样的空间

类似于新生代的生存者1区和0区

缺点:大量占用空间,需要进行对象赋值

标记压缩算法

第一阶段和标记清楚算法一样

第二阶段对剩余对象进行移动(压缩)

如果堆的内存空间是连续的,就会发生指针碰撞

如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(BumptHe Pointer)。

finalization机制

JAVA的Object类提供了finalize方法,可以由用户对回收前的行为进行操作

由一个专门的线程去调用fnalize方法

对象的生命周期有三个状态

可触及的

可复活的

不可触及的

分代收集算法

年轻代和老年代使用不同的垃圾回收算法

年轻代使用复制算法

老年代使用标记清除和标记压缩混合算法

增量收集算法

垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程,依次反复,直到垃圾收集完成

System.gc

在默认请跨国下调用会触发Full GC 同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存

System.gc无法保证垃圾收集器的调用

垃圾回收的并行与并发

并行

并行 有多条垃圾线程进行垃圾回收操作

串行 只有一条垃圾回收线程在工作

并发

指用户进程和垃圾回收线程同时执行

安全点和安全区域

程序在执行的时候并非在所有地方开始GC

程序只有在特定安全的位置,才能开始GC,这个位置被称为安全点

这个安全点一般是运行比较长的命令的前后

安全区的存在是为了防止有的进程进行了睡眠和阻塞 无法运行到安全点,从而无法进行GC

在安全区域内的引用不会发生变化,所以在安全区的任何时间去GC都是安全的

可以视为另一种形式的安全点

如何在发生GC时,确保所有线程都运行到安全点呢

抢占式中断

中断所有线程,如果有虚拟机不在安全点就恢复线程

主动式中断

设置一个中断标志,各个线程运行到safepoinit的时候,轮询这个标志,如果中断为真就进行中断,否者进程进行挂起

引用

如果有个对象:内存空间够的时候对象可以存在,内存不够的情况也可以先进行回收去腾出空间

jvm里也存在这样的对象,将他们区分为四个引用 强 弱 软 虚 引用

垃圾回收器

垃圾回收器一般存在两个指标,一个是吞吐量一个是延迟时间,往往两者只能取其1

七种经典的垃圾回收器

这七个垃圾回收器并不是每一个都能回收整个堆

而是有的作用于年轻代,有的作用于老年代

垃圾回收器的组合方案

Serial 回收器

串行回收器

是串行回收器,对于单个cpu来说,Serial 回收器没有线程交互的开销,可以获得单线程最高垃圾收集效率

与Serial 回收器 搭配的回收器是Serial Old GC 

一般都是嵌入式设备在使用,对于交互性较强的应用来讲,不会使用串行回收器

ParNew回收器

并行回收器

可以理解为Serial回收器的多线程版本,里面有很多代码都是一样的

可以充分利用物理硬件的资源优势,更加快速的完成垃圾收集

采用并行回收的方式对垃圾进行回收

与它搭档的回收器有GMS MSC(Serial Old)

现在是基本上不用了

Parallel回收器

并行回收器

为什么要多次一举的提供Parallel和ParNew?

可以控制GC的吞吐量

自适应调节吞吐量是和ParNew垃圾回收器的区别

高吞吐量可以高效率的利用CPU 时间,尽快完成运算任务,主要适合在后台运算 不需要太多及时交互的任务,比如批处理,科学计算等等

与它搭档的回收器有Parallel Old GC 和Serial Old GC

但是由于Serial Old GC是串行化的垃圾回收器,所以一般不会用Serial Old GC。

老年代一般使用Parallel Old GC

JDK8中作为默认的垃圾回收器

CMS 回收器

并发回收器

注重低延时

使用垃圾清除算法 Concurrent Mark Sweep 简称CMS

在需要系统响应时间的系统时比较合适

CMS垃圾回收器存在的问题

1. 虽然是并发的垃圾回收器,但是仍然存在两次的STW

2. CMS回收器不能等待老年代满的时候在进行GC,由于用户线程并发执行,可能导致OOM

3. 由于CMS回收器采用标记清除算法,所以可能会产生内存碎片

4. 可能会产生浮动垃圾,也就是在并发标记阶段,新产生的垃圾对象无法被成功标记,需要等到下一次GC才能进行回收

JDK14进行了删除

 小结

G1回收器

区域分代化

现在的业务越来越大 复杂 用户越来越多,没有GC应用程序正常进行,而经常造成STW的GC又跟不上实际需求

 G1回收器 进一步降低暂停时间 同时兼顾良好的吞吐量

Region

不为老年代和新生代分配连续的空间,而是把他们细化成多个regio

region可以属于新生代老年代等等

关于H区

对于堆中的大对象,默认直接会被分配到老年代,如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分一个H区

回收过程

Young GC

当年轻代的Eden区用完时开始年轻代的回收过程 G1的年轻代收集阶段时一个并行的独占式收集器

Old GC

当堆的使用达到临界值时(默认45%)开始进行老年代并发标记过程

Mix GC

当老年代并发标记完成之后,进入混合回收期,移动老年代的存活对象到空闲空间,这些空闲区间也就成了老年代的一部分,和年轻代不同,MixGC一次性只回收少部分(优先级高的)老年代对象同时老年代的GC可以和年轻代的GC一起进行

Remembered Set

G1相比其他计数器需要多10-20%的存储空间,就是用来存储记忆集的

一个Region不可能是孤立的,一个Region可能会被各种区所引用,对于这种情况,只能对堆中的所有对象进行扫描,这会是一大笔系统开销

比如回收Eden还需要考虑Old区的情况

所以引用Remembered Set来避免全局扫描

Remembered Set存放在Region里面

例如Region2的Remembered Set中记录了Region1和Region2被当前Region所引用

年轻代GC

当Eden区内存不足触发YoungGC

年轻代回收过程

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值