一问三不知的Java虚拟机

有人可能会说,你一个做Android开发的要研究虚拟机干嘛。嗯,可能是女朋友不在身边闲的。但是不学不行啊,现在出门面试,面试官都是各种原理,各种虚拟机知识的问。然后就是各种一问三不知,最后就是各种回家等通知的GG了。所以不学Java虚拟机的Android开发不是好的攻城狮。

一、虚拟机的历史

这个去网上一搜一大堆,都写烂了。这里就不再写了。想了解的去百度一下吧。

二、JVM是干嘛的

我们先来看一张我扣来的JDK图

可以看到JVM是Java框架中偏底层的技术模块。那么它是干什么用的呢?我们都知道Java其实是跨平台的。那么这个跨平台如何实现呢?JVM就是干这个的。它会将我们的Java语言翻译成二进制机器码,然后运行在各个平台上。

三、JVM的运行过程——运行时数据区

JVM在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区。其实前面在写多线程三部曲的时候也有提到过。这些数据区分别如下图:

程序计数器、虚拟机栈、本地方法栈、Java堆、方法区、直接内存

而Java是多线程的,这些数据区有可以细分为线程私有和线程公有区:

线程私有:程序计数器、虚拟机栈、本地方法栈。每个线程都会对应一份这样的数据区。

线程公有:Java堆(垃圾回收主要工作区域)、方法区

四、JVM中各数据区的功能和作用

A、线程私有:

(1)程序计数器:

官方点的解释是指向当前线程正在执行的字节码指令的地址(行号)。那么为什么要程序计数器呢?我们都知道Java是多线程的,既然是多线程就意味着会有线程间切换、线程间通信等一系列的线程间操作。而在执行这么多的线程间操作时如何保证每个线程的程序都按照代码的步骤正常执行呢。这时候程序计数器就是关键了,它会帮我们记录当前线程执行到的位置(在字节码中记录的这些位置统称为指令地址)。

(2)虚拟机栈:

讲虚拟机栈之前需要先了解栈(Stack)这种数据结构。这种数据结构的特点就是先进后出。这与我们前面在讲多线程时提到的队列这种数据结构刚好相反。

那么什么是虚拟机栈呢:虚拟机栈就是存储当前线程运行方法所需要的数据、指令、返回地址等信息的。(设置虚拟机栈大小:-Xss)。每个线程在创建时都会创建一个虚拟机栈,内部保存一个个的栈帧,这些栈帧对应着一次次的函数调用。在一个时间点,对应的只会有一个活动的栈帧,通常叫做当前帧,方法所在的类叫做当前类。如果在当前正在执行的方法内调用了其他方法,对应的将会创建一个新的栈帧,成为新的当前帧,一直到它返回结果或者执行结束。java对虚拟机栈只会执行两种操作:压栈和出栈。栈帧中存储着对应方法的局部变量表,操作数栈,动态链接和方法的返回地址。即虚拟机栈中存储着栈帧,而每个栈帧里面存储着当前方法所需的数据、指令和返回等信息。在JVM中每个方法在执行的同时都会创建一个栈帧,而栈帧还可以划分为:

局部变量表:顾名思义就是局部变量的表,用于存放我们的局部变量的(如this、入参、方法内创建的局部对象的引用地址、局部变量等)。首先它是一个32位的长度,主要存放我们的Java的八大基础数据类型,一般32位就可以存放下,如果是64位的就使用高低位占用两个也可以存放下,如果是局部的一些对象,比如我们的Object对象,我们只需要存放它的一个引用地址即可。

操作数栈:存放我们方法执行的操作数的,它就是一个栈,先进后出的栈结构,用来不停的执行入栈出栈操作的,操作的的元素可以是任意的java数据类型,所以我们知道一个方法刚刚开始的时候,这个方法的操作数栈就是空的,操作数栈运行方法时会一直运行入栈/出栈的操作。

动态链接:Java语言特性是多态(需要类加载、运行时才能确定具体的方法),动态特性(Groovy、JS、动态代理)

返回地址:正常返回(调用程序计数器中的地址作为返回)、异常的话(通过异常处理器表<非栈帧中的>来确定)

(3)本地方法栈:

本地方法栈(Native Method Stacks)与 Java 虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的 Native 方法服务。Navtive 方法是 Java 通过 JNI 直接调用本地 C/C++ 库,可以认为是 Native 方法相当于 C/C++ 暴露给 Java 的一个接口,Java 通过调用这个接口从而调用到 C/C++ 方法(感兴趣的朋友可以去了解一下NDK开发,后面我们也会单独写一系列NDK开发相关的文章)。当线程调用 Java 方法时,虚拟机会创建一个栈帧并压入 Java 虚拟机栈。然而当它调用的是 native 方法时,虚拟机会保持 Java 虚拟机栈不变,也不会向 Java 虚拟机栈中压入新的栈帧,虚拟机只是简单地动态连接并直接调用指定的 native 方法

 B、线程公有:

(1)方法区:

我们都知道Java中每个类都对应一个Class对象保存这个类的信息,方法区就是用来保存这个类信息的区域。同时它还会保存常量(包括运行时常量池)、静态变量以及编译器编译后的代码等。在方法区中有一个非常重要的部分就是运行时常量池,它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。当然并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的intern方法。

(2)堆:

这块区域也是我们今天会重点讲解的一个区域,它主要用来存储大部分的对象实列(也会有在栈上分配的对象,这个就要涉及到内存的逃逸分析技术。我也还不是太了解,接触过JVM优化的同学可能了解得更多一点。有兴趣得同学可以了解一下这篇逃逸分析的文章)、数组(当然数组引用是存放在Java栈中的)等。在讲堆之前需要先弄明白如何去区分一个堆和虚拟机栈。首先虚拟机栈是以栈帧的方式存储方法调用的过程,并存储方法调用过程中基本数据类型的变量(int、short、long、byte、float、double、boolean、char等)以及对象的引用变量,其内存分配在栈上,变量出了作用域就会自动释放;而堆内存是用来存储Java中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中,在C语言中堆是唯一一个程序员可以管理的内存区域,需要我们手动申请和释放的内存区域,在Java中我们不需要关心这块内存的释放问题,Java的垃圾回收机制会帮我们处理,因此这部分空间也是Java垃圾收集器管理的主要区域。说到垃圾回收机制,就需要说一下JVM对堆内存区的划分以及分配方式了。

首先我们创建一个对象时,JVM会去判断当前堆内存是否是规整状态,如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”,如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

这里我们来看一下什么又是对内存规整状态和不规整状态。

 由于Java是多线程的,堆又是线程共享区域,所以除了如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况,而解决这一线程安全的方案有两种:

(1)、CAS机制:即JVM在堆中为每个对象分配内存时都会做一下CAS操作(即比较和交换操作):一个期望操作前的值A(旧值)和一个新值。在操作期间会先比较旧值有没有变化,如果没有变化,才交换成新值,否则不进行交换。

 (2)分配缓冲:将内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块私有内存,也就是本地线程分配缓冲(Thread Local Allocation Buffer,简称TLAB),如果设置了虚拟机参数 -XX:UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden区域申请一块继续使用。TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已(这也就是前面提到的逃逸分析技术,对象在栈上分配的情况)。

五、JVM的内存分配和垃圾回收

(1)内存分区

JVM将堆内存进行了进一步的划分,如下:

a、新生代(PSYoungGen):Minor GC。新生代又细分成如下三个空间

1)、Eden空间

2)、From Survivor空间

3)、To Survivor空间

Eden空间用来优先分配对象、数组,From和To空间是个交换区。默认情况下这三个空间的内存占比是8:1:1。先不要纠结为什么是8:1:1。

b、老年代(ParOldGen):Full GC

(2) GC如何判断一个对象的存活状态

a、引用计数法

顾名思义当一个对象被另一个对象引用时,将该对象的引用计数+1。相反当该对象被引用对象释放时则-1。当这个 对象的引用计数为0时,则认为这个对象是垃圾对象可回收。但是这种方法是不靠谱的,因为这种方法是无法判断一个对象是否真实无用可回收的。比如对象A引用了对象B,同时对象B也引用了A。虽然这两个对象在各自的引用者中都没有被用到,但是这个时候引用计数法就无法判断这个对象是否是可回收对象了。很可能 就会造成我们说的内存溢出的情况。

<-- 背景 -->
// 对象objA 和 objB 都有字段 name
// 两个对象相互进行引用,除此之外这两个人对象没有任何引用
        objA.name = objB;
        objB.name = objA;

   <-- 问题 -->
// 实际上这两个对象已经不可能再被访问,应该要被垃圾收集器进行回收
// 但因为他们相互引用,所以导致计数器不为0,这导致引用计数算法无法通知垃圾收集器回收该两个对象

 b、可达性分析算法

既然引用计数法有缺陷那么就换一种啊,可达性分析算法就出现了。那么什么是可达性分析呢?在Java中可达性分析首先要确定Gc Roots对象。这个Gc Roots对象是个啥对象?一脸懵逼。所谓Gc Roots对象就是如下这些对象实列:

1)、方法区中类静态属性引用的对象,以及方法区中常量引用的对象

2)、虚拟机栈(本地变量表)中引用的对象

3)、本地方法栈(JNI开发Native方法)中引用的对象。

public class GcRootDemo {
    static Object gcRoots = new Object();//方法区类静态属性对象,GcRoots对象
    Object object = new Object();//方法区非静态属性对象,不是GcRoots对象

    public static void main(String[] args){
        Object object1 = gcRoots;//这里不是赋值,在对象中是引用,传递的是右边对象的引用地址。
        Object object2 = object1;//方法执行完后,若线程未结束,则object2依旧可达,不会回收。
    }
    public void stack(){
        Object gcRoots = new Object();//虚拟机栈(本地变量表)中引用的对象
        Object object1 = gcRoots;//在方法没运行完(出栈前)object1都是可达的
    }
    public void demo(){
        Object newObject = object;//由于object不是GcRoots对象,所以newObject不可达,方法运行完后即可回收。
    }

    public native void getName(Object o);//native方法中引用的对象是GcRoots对象。当底层有对象引用对象o时,则底层对象在方法没运行完之前都是可达的。
}

 (3)、Android 中的四种引用关系

a、强引用:new出来的对象都是强引用。被强引用的对象无法被垃圾回收。即使内存不足时也无法回收,就有可能造成OOM

b、软引用SoftReference:被软引用的对象,当内存不足时(即将OOM时),则被回收。

c、弱引用WeakReference:垃圾回收线程扫描内存区时,发现被弱引用的对象立即回收。

d、虚引用PhantomReference:任何时候都有可能被回收。

(4)、如何回收内存

可达性分析法只是判断对象是否可达,但还不会真正回收对象。可以理解为只是标记出哪些对象是可回收的对象。真正要实现对象回收还经过如下的垃圾回收算法来处理。

a、复制回收算法:它的作用域主要是年轻代。

它会将内存一分为二(其实就是前面说的From、To两块内存区)。一块用来正常使用,一块空闲备用等待复制。判断完对象存活状态后,进行回收时,将存活对象复制到备用空间,然后清空之前的那块内存空间等待备用。复制回收算法的缺点就是内存只有50%利用率。这里就可以解释一下为什么前面的年轻代内存区占比是8:1:1了,在Java中我们通常认为有90%的对象是不需要回收的,只有10%的对象需要被回收,而复制回收算法需要预留一份与这个10%一样大小的内存区来备用。所以就得出了8:1:1的空间占比。复制回收算法就是在From和To之间进行内存Copy。

b、标记-清除算法:主要作用域是老年代

判断完内存区对象的存活状态后,会对垃圾对象做一个可回收的标记。等垃圾回收器扫描完所有内存后,一次性清除被标记为可回收的对象。标记算法的缺点是垃圾回收后内存空间是不连续的,存在内存碎片(也就是前面提到的内存不规整状态)。

 c、标记-整理算法:主要作用域是老年代

标记清除算法既然有内存碎片的缺点,那么能否克服这个缺点呢,标记整理算法就是在标记清除算法的基础上解决了垃圾回收后内存碎片的问题,即清除垃圾对象后,会对内存区进行整理,使其成为规整状态。可是既然要整理内存区,就必然要进行内存移动,就会降低效率。所以它的效率比标记清除算法要低。

(5)内存分配策略

a、新建的对象、数组优先在年轻代的Eden区分配。

b、大对象直接进入老年代(即该对象在Eden、From、To这三个空间都放不下了)。这种策略和动态年龄判断又叫做空间担保。

c、长期存活的对象进入老年代。(可以认为垃圾回收器每扫描一次对象就给对象的年龄age加1,当age>=15时,就认为该对象是长期存活的对象)

d、动态年龄判断。当进行一次垃圾回收时,发现From或To备用空间放不下所有对象时,则将该空间age>1的对象都进入老年代。

(6)各垃圾收集器的工作区域和流程

在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。单线程与多线程下各个垃圾收集器的工作区域分布图如下:

 不要问我这些收集器怎么来的,有什么区别。因为我也不知道,这里列出来只是装一下X。又兴趣的去一个个百度吧。

既然知道了垃圾收集器的工作区域,那么它的工作流程是什么样子的呢?

(1)单线程收集器的工作流程

 (2)多线程并行收集器的工作流程

 并行收集:垃圾收集的多线程的同时进行。

并发收集:垃圾收集的多线程和应用的多线程同时进行。

(7)内存泄漏和内存溢出

内存溢出:当内存不足,GC又来不及回收,导致新的对象无法分配内存空间,即为内存溢出。

内存泄漏:当一个对象由于各种原因导致生命周期无法结束,GC一直无法回收该对象时,这样就会导致总的可用内存空间变小了。这时候就叫做内存泄漏。当内存泄漏严重的时候就会很容易造成内存溢出。

 

 

 

发布了29 篇原创文章 · 获赞 3 · 访问量 901

猜你喜欢

转载自blog.csdn.net/LVEfrist/article/details/94204029
今日推荐