jvm理解

1.堆栈

JVM运行字节码时,所有的操作基本都是围绕两种数据结构,一种是堆栈(本质是栈结构),还有一种是队列,如果JVM执行某条指令时,该指令需要对数据进行操作,那么被操作的数据在指令执行前,必须要压到堆栈上,JVM会自动将栈顶数据作为操作数。如果堆栈上的数据需要暂时保持起来时,那么它就会被存储到局部变量队列上。

int main() {     //实现一个最简的a+b功能,并存入变量c
    int a = 10;
    int b = 20;
    int c = a + b;
    return c;
}

0: bipush        10     //0是程序偏移地址,然后是指令,最后是操作数
2: istore_1

这一步操作实际上就是使用bipush将10推向栈顶,接着使用istore_1将当前栈顶数据存放到第二个局部变量中,也就是a,所以这一步执行的是int a = 10操作。

3: bipush        20
5: istore_2

同上,这里执行的是int b = 20操作。

6: iload_1
7: iload_2
8: iadd

这里是将第二和第三个局部变量放到栈中,也就是取a和b的值到栈中,最后iadd操作将栈中的两个值相加,结果依然放在栈顶。

9: istore_3
10: iload_3
11: ireturn

将栈顶数据存放到第四个局部变量中,也就是c,执行的是int c = 30,最后取出c的值放入栈顶,使用ireturn返回栈顶值,也就是方法的返回值。至此,方法执行完毕。

        实际上我们发现,JVM执行的命令基本都是入栈出栈等,而且大部分指令都是没有操作数的,传统的汇编指令有一操作数、二操作数甚至三操作数的指令,相比C编译出来的汇编指令,执行起来会更加复杂,实现某个功能的指令条数也会更多,所以Java的执行效率实际上是不如C/C++的,虽然能够很方便地实现跨平台,但是性能上大打折扣,所以在性能要求比较苛刻的Android上,采用的是定制版的JVM,并且是基于寄存器的指令集架构。在某些情况下,我们可以使用JNI机制来通过Java调用C/C++编写的程序以提升性能(也就是本地方法,使用到native关键字)

2.jvm启动流程:

 配置JVM装载环境-> 解析虚拟机参数- >设置线程栈大小->执行JavaMain方法

1.首先进行初始化操作: 

InitLauncher(javaw);
DumpState();
if (JLI_IsTraceLauncher()) {
    int i;
    printf("Command line args:\n");
    for (i = 0; i < argc ; i++) {
        printf("argv[%d] = %s\n", i, argv[i]);
    }
    AddOption("-Dsun.java.launcher.diag=true", NULL);
}

2.选择合适的jre版本

SelectVersion(argc, argv, &main_class);

3.创建合适的jvm执行环境,例如需要确定模型数据,32位还是64位,以及jvm本身的一些配置在jvm.config文件中读取和解析。

4.jvm初始化,有所在的平台进行

return JVMInit(&ifn, threadStackSize, argc, argv, mode, what, ret);

 3.内存管理

        Java只支持直接使用基本数据类型和对象类型,至于内存到底如何分配,并不是由我们来处理,而是JVM帮助我们进行控制,这样就帮助我们节省很多内存上的工作,虽然带来了很大的便利,但是,一旦出现内存问题,我们就无法像C/C++那样对所管理的内存进行合理地处理,因为所有的内存操作都是由JVM在进行,只有了解了JVM的内存管理机制,我们才能够在出现内存相关问题时找到解决方案。

3.1内存区域划分

 我们可以看到,内存区域一共分为5个区域,其中方法区和堆是所有线程共享的区域,随着虚拟机的创建而创建,虚拟机的结束而销毁,而虚拟机栈、本地方法栈、程序计数器都是线程之间相互隔离的,每个线程都有一个自己的区域,并且线程启动时会自动创建,结束之后会自动销毁。内存划分完成之后,我们的JVM执行引擎和本地库接口,也就是Java程序开始运行之后就会根据分区合理地使用对应区域的内存了。

3.2程序计数器

        首先我们来介绍一下程序计数器,它和我们的传统8086 CPU中PC寄存器的工作差不多,因为JVM虚拟机目的就是实现物理机那样的程序执行。在8086 CPU中,PC作为程序计数器,负责储存内存地址,该地址指向下一条即将执行的指令,每解释执行完一条指令,PC寄存器的值就会自动被更新为下一条指令的地址,进入下一个指令周期时,就会根据当前地址所指向的指令,进行执行。

而JVM中的程序计数器可以看做是当前线程所执行字节码的行号指示器,而行号正好就指的是某一条指令,字节码解释器在工作时也会改变这个值,来指定下一条即将执行的指令。

因为Java的多线程也是依靠时间片轮转算法进行的,因此一个CPU同一时间也只会处理一个线程,当某个线程的时间片消耗完成后,会自动切换到下一个线程继续执行,而当前线程的执行位置会被保存到当前线程的程序计数器中,当下次轮转到此线程时,又继续根据之前的执行位置继续向下执行。

程序计数器因为只需要记录很少的信息,所以只占用很少一部分内存。

3.3虚拟机栈

        虚拟机栈就是一个非常关键的部分,看名字就知道它是一个栈结构,每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(其实就是栈里面的一个元素),栈帧中包括了当前方法的一些信息,比如局部变量表、操作数栈、动态链接、方法出口等。

image-20220131110349472

         其中局部变量表就是我们方法中的局部变量,实际上局部变量表在class文件中就已经定义好了,操作数栈就是我们之前字节码执行时使用到的栈结构; 每个栈帧还保存了一个可以指向当前方法所在类的运行时常量池,目的是:当前方法中如果需要调用其他方法的时候,能够从运行时常量池中找到对应的符号引用,然后将符号引用转换为直接引用,然后就能直接调用对应方法,这就是动态链接(我们还没讲到常量池,暂时记住即可,建议之后再回顾一下),最后是方法出口,也就是方法该如何结束,是抛出异常还是正常返回。
 

public class Main {
    public static void main(String[] args) {
        int res = a();
        System.out.println(res);
    }

    public static int a(){
        return b();
    }

    public static int b(){
        return c();
    }

    public static int c(){
        int a = 10;
        int b = 20;
        return a + b;
    }
}

执行流程:

image-20220131142625842

 接着我们继续往下,到了0: invokestatic #2 // Method a:()I时,需要调用方法a(),这时当前方法就不会继续向下运行了,而是去执行方法a(),那么同样的,将此方法也入栈,注意是放入到栈顶位置,main方法的栈帧会被压下去:

image-20220131143641690

 这时,进入方法a之后,又继而进入到方法b,最后在进入c,因此,到达方法c的时候,我们的虚拟机栈变成了:

image-20220131144209743

         现在我们依次执行方法c中的指令,最后返回a+b的结果,在方法c返回之后,也就代表方法c已经执行结束了,栈帧4会自动出栈,这时栈帧3就得到了上一栈帧返回的结果,并继续执行,但是由于紧接着马上就返回,所以继续重复栈帧4的操作,此时栈帧3也出栈并继续将结果交给下一个栈帧2,最后栈帧2再将结果返回给栈帧1,然后栈帧1就可以继续向下运行了,最后输出结果。
image-20220131144955668

3.4堆

        堆是整个Java应用程序共享的区域,也是整个虚拟机最大的一块内存空间,而此区域的职责就是存放和管理对象和数组,而我们马上要提到的垃圾回收机制也是主要作用于这一部分内存域。        

3.5方法区

        方法区也是整个Java应用程序共享的区域,它用于存储所有的类信息、常量、静态变量、动态编译缓存等数据,可以大致分为两个部分,一个是类信息表,一个是运行时常量池。方法区也是我们要重点介绍的部分

image-20220201140516096

         类信息表存放的是当前应用程序加载的所有类信息,包括类的版本,方法,接口等信息。同时会将编译时生成的常量池数据全部存放到运行时常量池中。常量也并不是只能从类信息中获取,也可能会在程序运行时,有新的常量加入进来。

       3.5.1 String类对应的常量池优化:

                如果new 两个String对象,那么如果用 == 判断,得到的结果肯定是不相等的, 因为内存地址不同,这是因为,这两个对象实际上会在堆中存放,保存在不同的地址

image-20220201141848804

                但是如果不 new String对象的话,这样创建的字符串,用==比较,却是会返回 相等。

            因为 直接使用双引号赋值,会先在常量池中查询是否含有相同的字符串,如果有,则将引用直接指向这个字符串,如果不存在则会在常量池中生成一个对应的字符串,再讲引用指向这个字符串。

public static void main(String[] args) {
    String str1 = "abc";
    String str2 = "abc";

    System.out.println(str1 == str2);
    System.out.println(str1.equals(str2));
}

image-20220201142710405

 内存区用途总结:

        (线程独有) 程序计数器:保存当前程序执行到的位置

         (线程独有)虚拟机栈:通过栈帧来维持方法调用顺序,帮助程序有序运行。

        (线程独有) 本地方法栈:作用与本地方法相同

            堆: 所有的对象和数组都存放在这里

            方法区: 存放这类信息,及时编译器的代码缓存,运行时常量池

3.6堆外内存

        除了堆内存可以存放对象数据以外,我们也可以申请堆外内存也就是不受到jvm管控的内存,这种内存是需要手动申请和释放的,Unsafe类,甚至没有直接调用的方法,需要通过反射调用。

4.垃圾回收机制:

4.1对象存活判定算法

        目前比较主流的编程语言(包括Java),一般都会使用可达性分析算法来判断对象是否存活,它采用了类似于树结构的搜索机制。

        首先每个对象的引用都有机会成为树的根节点(GC Roots),可以被选定作为根节点条件如下:

  1. 位于虚拟机栈的栈帧中的本地变量表中所引用到的对象(其实就是我们方法中的局部变量)同样也包括本地方法栈中JNI引用的对象。
  2. 类的静态成员变量引用的对象。
  3. 方法区中,常量池里面引用的对象,比如我们之前提到的String类型对象。
  4. 被添加了锁的对象(比如synchronized关键字)
  5. 虚拟机内部需要用到的对象。

image-20220222125507229

 一旦已经存在的根节点不满足存在的条件时,那么根节点与对象之间的连接将被断开。此时虽然对象1仍存在对其他对象的引用,但是由于其没有任何根节点引用,所以此对象即可被判定为不再使用。比如某个方法中的局部变量引用,在方法执行完成返回之后:

image-20220222130350950

 这样就能很好地解决我们刚刚提到的循环引用问题,我们再来重现一下出现循环引用的情况:

image-20220222130903349

 可以看到,对象1和对象2依然是存在循环引用的,但是只有他们各自的GC Roots断开,那么就会变成下面这样

 所以,我们最后进行一下总结:如果某个对象无法到达任何GC Roots,则证明此对象是不可能再被使用的。

最终判定

   Object类的finalize()方法正是最终判定方法,如果子类重写了此方法,那么子类对象在被判定为可回收时,会进行二次确认,也就是执行finalize()方法,而在此方法中,当前对象是完全有可能重新建立GC Roots的!所以,如果在二次确认后对象不满足可回收的条件,那么此对象不会被回收,巧妙地逃过了垃圾回收的命运。

public class Main {
    private static Test a;
    public static void main(String[] args) throws InterruptedException {
        a = new Test();

        //这里直接把a赋值为null,这样前面的对象我们不可能再得到了
        a  = null;

        //手动申请执行垃圾回收操作(注意只是申请,并不一定会执行,但是一般情况下都会执行)
        System.gc();

        //等垃圾回收一下()
        Thread.sleep(1000);

        //我们来看看a有没有被回收
        System.out.println(a);
    }

    private static class Test{
        @Override
        protected void finalize() throws Throwable {
            System.out.println(this+" 开始了它的救赎之路!");
            a = this;
        }
    }
}

注意finalize()方法并不是在主线程调用的,而是虚拟机自动建立的一个低优先级的Finalizer线程(正是因为优先级比较低,所以前面才需要等待1秒钟)进行处理,我们可以稍微修改一下看看:

private static class Test{
    @Override
    protected void finalize() throws Throwable {
        System.out.println(Thread.currentThread());
        a = this;
    }
}

同时,同一个对象的finalize()方法只会有一次调用机会,也就是说,如果我们连续两次这样操作,那么第二次,对象必定被回收:

public static void main(String[] args) throws InterruptedException {
    a = new Test();
    //这里直接把a赋值为null,这样前面的对象我们不可能再得到了
    a  = null;
    //手动申请执行垃圾回收操作(注意只是申请,并不一定会执行,但是一般情况下都会执行)
    System.gc();
    //等垃圾回收一下
    Thread.sleep(1000);
    
    System.out.println(a);
    //这里直接把a赋值为null,这样前面的对象我们不可能再得到了
    a  = null;
    //手动申请执行垃圾回收操作(注意只是申请,并不一定会执行,但是一般情况下都会执行)
    System.gc();
    //等垃圾回收一下
    Thread.sleep(1000);
    
    System.out.println(a);
}

当然,finalize()方法也并不是专门防止对象被回收的,我们可以使用它来释放一些程序使用中的资源等。image-20220222141854678

 垃圾回收算法

分代收集机制
        

        实际上,如果我们对堆中的每一个对象都依次判断是否需要回收,这样的效率其实是很低的,那么有没有更好地回收机制呢?第一步,我们可以对堆中的对象进行分代管理。

        比如某些对象,在多次垃圾回收时,都未被判定为可回收对象,我们完全可以将这一部分对象放在一起,并让垃圾收集器减少回收此区域对象的频率,这样就能很好地提高垃圾回收的效率了。

        因此,Java虚拟机将堆内存划分为新生代、老年代和永久代(其中永久代是HotSpot虚拟机特有的概念,在JDK8之前方法区实际上就是采用的永久代作为实现,而在JDK8之后,方法区由元空间实现,并且使用的是本地内存,容量大小取决于物理机实际大小,之后会详细介绍)这里我们主要讨论的是新生代和老年代。

        不同的分代内存回收机制也存在一些不同之处,在HotSpot虚拟机中,新生代被划分为三块,一块较大的Eden空间和两块较小的Survivor空间,默认比例为8:1:1,老年代的GC评率相对较低,永久代一般存放类信息等(其实就是方法区的实现)如图所示:
image-20220222151708141

         首先,所有新创建的对象,在一开始都会进入到新生代的Eden区(如果是大对象会被直接丢进老年代),在进行新生代区域的垃圾回收时,首先会对所有新生代区域的对象进行扫描,并回收那些不再使用对象:

image-20220222153104582

        接着,在一次垃圾回收之后,Eden区域没有被回收的对象,会进入到Survivor区。在一开始From和To都是空的,而GC之后,所有Eden区域存活的对象都会直接被放入到From区,最后From和To会发生一次交换,也就是说目前存放我们对象的From区,变为To区,而To区变为From区:

image-20220222154032674

 接着就是下一次垃圾回收了,操作与上面是一样的,不过这时由于我们From区域中已经存在对象了,所以,在Eden区的存活对象复制到From区之后,所有To区域中的对象会进行年龄判定(每经历一轮GC年龄+1,如果对象的年龄大于默认值为15,那么会直接进入到老年代,否则移动到From区)

image-20220222154828416

最后像上面一样交换To区和From区,之后不断重复以上步骤。

而垃圾收集也分为:

Minor GC - 次要垃圾回收,主要进行新生代区域的垃圾收集。
        触发条件:新生代的Eden区容量已满时。
Major GC - 主要垃圾回收,主要进行老年代的垃圾收集。
Full GC - 完全垃圾回收,对整个Java堆内存和方法区进行垃圾回收。
        触发条件1:每次晋升到老年代的对象平均大小大于老年代剩余空间
        触发条件2:Minor GC后存活的对象超过了老年代剩余空间
        触发条件3:永久代内存不足(JDK8之前)
        触发条件4:手动调用System.gc()方法

空间分配担保

        我们可以思考一下,有没有这样一种极端情况(正常情况下新生代的回收率是很高的,所以说不用太担心会经常出现这种问题),在一次GC后,新生代Eden区仍然存在大量的对象(因为GC之后存活对象会进入到一个Survivor区,但是很明显这时已经超出Survivor区的容量了,肯定是装不下的)那么现在该怎么办?

        这时就需要用到空间分配担保机制了,可以把Survivor区无法容纳的对象直接送到老年代,让老年代进行分配担保(当然老年代也得装得下才行)在现实生活中,贷款会指定担保人,就是当借款人还不起钱的时候由担保人来还钱。

        当新生代无法容纳更多的的对象时,可以把新生代中的对象移动到老年代中,这样新生代就腾出了空间来容纳更多的对象。

        好,那既然新生代装不下就丢给老年代,那么要是老年代也装不下新生代的数据呢?这时,老年代肯定担保人是当不成了,那么这样的话,首先会判断一下之前的每次垃圾回收进入老年代的平均大小是否小于当前老年代的剩余空间,如果小于,那么说明也许可以放得下(不过也仅仅是也许,依然有可能放不下,因为判断的实际上只是平均值,万一这一次突然非常大呢),否则,会先来一次Full GC,进行一次大规模垃圾回收,来尝试腾出空间,再次判断老年代是否有空间存放,要是还是装不下,直接抛出OOM错误,摆烂。

image-20220222205605690

 具体回收算法:

        标记-清除算法

                首先标记出所有需要回收的对象,然后再依次回收掉被标记的对象,或是标记出所有不需要回收的对象,只回收未标记的对象。

image-20220222165709034

                 虽然此方法非常简单,但是缺点也是非常明显的 ,首先如果内存中存在大量的对象,那么可能就会存在大量的标记,并且大规模进行清除。并且一次标记清除之后,连续的内存空间可能会出现许许多多的空隙,碎片化会导致连续内存空间利用率降低。

        标记-复制算法(新生代)

        标记复制算法,实际上就是将内存区域划分为大小相同的两块区域,每次只使用其中的一块区域,每次垃圾回收结束后,将所有存活的对象全部复制到另一块区域中,并一次性清空当前区域。虽然浪费了一些时间进行复制操作,但是这样能够很好地解决对象大面积回收后空间碎片化严重的问题。

image-20220222210942507

         这种算法就非常适用于新生代(因为新生代的回收效率极高,一般不会留下太多的对象)的垃圾回收,而我们之前所说的新生代Survivor区其实就是这个思路,包括8:1:1的比例也正是为了对标记复制算法进行优化而采取的。

        标记-整理算法

         对于老年代来说,内部存储的大都是长期回收不到的对象,这样,如果采用标记复制法,就会导致空间的浪费。那么能否这样操作,在标记所有待回收对象之后,不急着去进行回收操作,而是将所有待回收的对象整齐排列在一段内存空间中,而需要回收的对象全部往后丢,这样,前半部分的所有对象都是无需进行回收的,而后半部分直接一次性清除即可。

image-20220222213208681

         虽然这样能保证内存空间充分使用,并且也没有标记复制算法那么繁杂,但是缺点也是显而易见的,它的效率比前两者都低。甚至,由于需要修改对象在内存中的位置,此时程序必须要暂停才可以,在极端情况下,可能会导致整个程序发生停顿(被称为“Stop The World”)。

        所以,我们可以将标记清除算法和标记整理算法混合使用,在内存空间还不是很凌乱的时候,采用标记清除算法其实是没有多大问题的,当内存空间凌乱到一定程度后,我们可以进行一次标记整理算法。

5.垃圾收集器实现

Serial收集器


这款垃圾收集器也是元老级别的收集器了,在JDK1.3.1之前,是虚拟机新生代区域收集器的唯一选择。这是一款单线程的垃圾收集器,也就是说,当开始进行垃圾回收时,需要暂停所有的线程,直到垃圾收集工作结束。它的新生代收集算法采用的是标记复制算法,老年代采用的是标记整理算法。

image-20220223104605648

 可以看到,当进入到垃圾回收阶段时,所有的用户线程必须等待GC线程完成工作

虽然缺点很明显,但是优势也是显而易见的:

  1. 设计简单而高效。
  2. 在用户的桌面应用场景中,内存一般不大,可以在较短时间内完成垃圾收集,只要不频繁发生,使用串行回收器是可以接受的。

ParNew收集器

这款垃圾收集器相当于是Serial收集器的多线程版本,它能够支持多线程垃圾收集:

image-20220223111344962

Parallel Scavenge/Parallel Old收集器

Parallel Scavenge同样是一款面向新生代的垃圾收集器,同样采用标记复制算法实现,在JDK6时也推出了其老年代收集器Parallel Old,采用标记整理算法实现:

image-20220223112108949

 与ParNew收集器不同的是,它会自动衡量一个吞吐量,并根据吞吐量来决定每次垃圾回收的时间,这种自适应机制,能够很好地权衡当前机器的性能,根据性能选择最优方案。

目前JDK8采用的就是这种 Parallel Scavenge + Parallel Old 的垃圾回收方案

元空间


        JDK8之前,Hotspot虚拟机的方法区实际上是永久代实现的。在JDK8之后,Hotspot虚拟机不再使用永久代,而是采用了全新的元空间。类的元信息被存储在元空间中。元空间没有使用堆内存,而是与堆不相连的本地内存区域。所以,理论上系统可以使用的内存有多大,元空间就有多大,所以不会出现永久代存在时的内存溢出问题。这项改造也是有必要的,永久代的调优是很困难的,虽然可以设置永久代的大小,但是很难确定一个合适的大小,因为其中的影响因素很多,比如类数量的多少、常量数量的多少等。

        因此在JDK8时直接将本地内存作为元空间(Metaspace)的区域,物理内存有多大,元空间内存就可以有多大,这样永久代的空间分配问题就讲解了,所以最终它变成了这样:image-20220223125137512

到此,我们对于JVM内存区域的讲解就基本完成了。
 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值