samwellwang

samwellwang

coder
twitter

Battle With JVM

当你开始想系统性的学习 Java 的时候,JVM 是不可能跳过的部分,哪个国家我没去过对象不是 JVM 创建的呢?之前的学习中遇到过很多 JVM 相关的内容,也只是了解,没有深入的学习。

当你开始想系统性的学习 Java 的时候,JVM 是不可能跳过的部分,哪个~国家我没去过~对象不是 JVM 创建的呢?之前的学习中遇到过很多 JVM 相关的内容,也只是了解,没有深入的学习。那这篇博客就记录一下学习 JVM 的过程。小部分内容是从网上摘录的。大部分都是查各种技术博客和《深入理解 java 虚拟机》然后经过我自己的理解叙述的。

Java 类的加载过程#

 当程序运行的时候需要一个类的时候,如果在内存中没有找到这个类,JVM 就会通过三个步骤来把类加载到内存中供程序的使用。分别是类的加载、连接、初始化。

加载#

 首先是类的加载,虚拟机通过 ClassLoader 来把字节码文件(.class)转化成内存形式的 Class 对象。这里的字节码文件可以是本地的. class,也可以是 jar 包里的文件,还可以是远程服务器提供的字节流。本质上就是 byte []。常见的 ClassLoader 有三个分别是 BootStrapClassLoader、ExtensionClassLoader、以及 AppClassLoader。他们都各司其职加载不同地方的类。

  • BootStarpClassLoader 负责加载 JVM 的核心类,位于 JAVA_HOME/lib/rt.jar。通常 java.* 下的类就是他加载的。

  • ExtensionClassLoader 负责加载扩展类,位于 JAVA_HOME/lib/ext/*.jar 中

  • AppClassLoader 就是加载我们自己写的类了,他会加载 ClassPath 中的 jar 包和我们自己写完编译的 class 文件。

  • 自定义类加载器负责加载特定的类,比如通过网络传来的经过加密的字节码文件。就需要配套一个自定义类加载器来解析加载。可以直接继承 URLClassLoader 类

双亲委派机制#

先来看一段 Java 源码中的 ClassLoader 类 loadclass (),配上我蹩脚的翻译。

protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        //首先,检查一下这个类是否已经被加载
        Class<?> c = findLoadedClass(name);
        //如果没有被加载
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                //如果父类加载器不为空
                if (parent != null) {
                    //由父类加载器加载,
                    // 双亲委派机制的重点
                    c = parent.loadClass(name, false);
                } else {
                //父类加载器为空,就是到头了。选择BootStarpClassLoader加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }

            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                long t1 = System.nanoTime();
                //相当于自定义加载器,override的方法
                c = findClass(name);

                // this is the defining class loader; record the stats
               //开始加载类 sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

 这段代码看懂就明白什么是双亲委派机制了,当要加载一个类的时候先判断是否已经加载了,防止重复加载,没有加载的话就委派给父类加载器加载。这是个递归函数,调到父类加载器还会判断是否已经加载。未加载继续在向上委派。当委派到头了没有父类加载器了,就交给 BootStrapClass 处理。BootStarpClass 加载了就返回,如果还没加载。就向下返回来给 “孩子” 加载,直至加载完成。如果到最后 AppClassLoader 或者自定义加载器还是没有加载成功则会抛出 ClassNotFound 异常。注意关注两点:其实每个加载器加载不到都会返回 ClassNotFound 但是被 catch 到了并没有异常处理。第二点就是最后的自定义类加载器的 findClass 是没有实现的,对于 ExtensionClassLoader 和 AppClassLoader 来说都会抛出 ClassNotFound。

连接#

第二步就是连接。连接分为三个步骤:验证、准备、解析;

验证#

验证是连接的第一步,这一阶段是确保了 class 文件的字节流中包含的信息符合当前虚拟就要求,不会危害虚拟机自身安全。特别是对于一些不是自己写的类,从网络传过来这种,做一个校验。

  • 文件格式验证
  • 元数据验证
  • 字节码验证
  • 符号引用验证
准备#

当完成字节码文件的校验之后,JVM 便会开始为类变量分配内存并初始化。这里需要注意两个关键点:

  • 内存分配的对象:Java 中的变量有「类变量」和「类成员变量」两种类型,「类变量」指的是被 static 修饰的变量,而其他所有类型的变量都属于「类成员变量」。在准备阶段,JVM 只会为「类变量」分配内存,而不会为「类成员变量」分配内存。「类成员变量」的内存分配需要等到初始化阶段才开始。

  • 初始化类型:在准备阶段,JVM 会为类变量分配内存,并为其初始化。但是这里的初始化指的是为变量赋予 Java 语言中该数据类型的零值,而不是用户代码里初始化的值

    public static int x = 666; //此时只会把int赋值为0; 666要等到初始化才能赋值 
    public final static int y = 666; //如果是常量则在此处就赋值为666
    
    解析#

    解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程, 解新动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行,分别对应于常量池的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_IntrfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info 和 CONSTANT_InvokeDynamic_info7 种常量类型,解析阶段中所说的直接引用与符号引用关系如下:

  • 符号引用 (Symlxiuc References): 符号引用以一组符号来描述所引用的日标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,特号引用与配組机实现的内存 1 布。局 11i - 美,引用的日标并不一定已组加裁到内存中

  • 直接引用 (Direct References): 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在

初始化#

到了这步,我们写的代码才会真正的开始执行,JVM 会根据语句执行顺序对类对象进行初始化。从代码角度,初始化阶段是执行类构造器 () 方法的过程。虚拟机规范触发初始化有且只有五种情况:

  • 遇到 new、getstatic、putstatic 或 invokestatic 这四条字节码指令(注意,newarray 指令触发的只是数组类型本身的初始化,而不会导致其相关类型的初始化,比如,new String [] 只会直接触发 String [] 类的初始化,也就是触发对类 [java.lang.String] 的初始化,而直接不会触发 String 类的初始化)时,如果类没有进行过初始化,则需要先对其进行初始化
    常见的情景是 new 一个对象、读取一个静态对象的字段或者方法。
  • 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main () 方法的那个类),虚拟机会先初始化这个主类。
  • 当使用 jdk1.7 动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getstatic,REF_putstatic,REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化。

 类初始化阶段是类加载过程的最后一步。在前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的 java 程序代码 (字节码)。

  在准备阶段,变量已经赋过一次系统要求的初始值 (零值);而在初始化阶段,则根据程序猿通过程序制定的主观计划去初始化类变量和其他资源,或者更直接地说:初始化阶段是执行类构造器 < clinit>() 方法的过程。() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块 static {} 中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。   

public class Test{
    static{
        i=0;
        System.out.println(i);//Error:Cannot reference a field before it is defined(非法向前应用)
    }
    static int i=1;
}

类构造器 () 与实例构造器 () 不同,它不需要程序员进行显式调用,虚拟机会保证在子类类构造器 () 执行之前,父类的类构造 () 执行完毕。由于父类的构造器 () 先执行,也就意味着父类中定义的静态语句块 / 静态变量的初始化要优先于子类的静态语句块 / 静态变量的初始化执行。特别地,类构造器 () 对于类或者接口来说并不是必需的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生产类构造器 ()。

  虚拟机会保证一个类的类构造器 () 在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的类构造器 (),其他线程都需要阻塞等待,直到活动线程执行 () 方法完毕。特别需要注意的是,在这种情形下,其他线程虽然会被阻塞,但如果执行 () 方法的那条线程退出后,其他线程在唤醒之后不会再次进入 / 执行 () 方法,因为 在同一个类加载器下,一个类型只会被初始化一次。

我们知道,在 Java 中, 创建一个对象常常需要经历如下几个过程:父类的类构造器 () -> 子类的类构造器 () -> 父类的成员变量和实例代码块 -> 父类的构造函数 -> 子类的成员变量和实例代码块 -> 子类的构造函数。

以上部分内容引自《深入理解 java 虚拟机》周志明著

JVM 运行时的数据区#

JDK8 之前的内存区域图如下:

JDK8 之后的内存区域:

下面就根据上面的图来分别解析一下各个数据区,以及为什么会改变。

  • 程序计数器:本身是一块很小的区域,线程私有。可以理解在进行上下文切换的时候记录了线程执行代码的位置。如果有多个线程正在执行指令,那么每个线程都会有一个程序计数器,它是线程私有的。在任意时刻,一个线程只允许执行一个方法的代码。每当执行到一条 Java 方法的指令时,程序计数器保存当前执行字节码的地址;若执行的为 native 方法,则 PC 的值为 undefined。类似于 CPU 中寄存器的程序计数器。
  • Java 虚拟机栈:同样也是线程私有的。描述了 Java 方法执行的内存模型,每个方法在执行的时候会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。各个方法执行的过程也就是栈帧的入栈和出栈过程。虚拟机栈规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展),如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常。
  • 本地方法栈:和虚拟机栈类似,为虚拟机使用到的 Native 方法服务;在 HotSpot JVM 中 Java 虚拟机栈和本地方法栈合二为一。
  • Java 堆:与栈不同,Java heap 是所有线程共享的区域,几乎所有的对象实例都在这里分配内存。所以对于大部分情况来说。堆也是最大的一块内存区域。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老年代;再细致一点的有 Eden 空间、From Survivor 空间、To Survivor 空间等。对于 GC 一会儿单独说。
  • 方法区(也称永久区):所有线程共享,一种特殊的堆结构。但是在 JVM 规范中却描述为(Non-Heap)它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。可以理解为 Java 堆的逻辑部分。在 JDK1.7 及以前的 HotSpot JVM 中,方法区位于永久代(PermGen)中。由于永久代内可能会发生内存泄露或溢出等问题而导致的 java.lang.OutOfMemoryError: PermGen ,JEP 小组从 JDK1.7 开始就筹划移除永久代,并且在 JDK 1.7 中把字符串常量,符号引用等移出了永久代。到了 Java 8,永久代被彻底地移出了 JVM,取而代之的是元空间(Metaspace)
  • 运行时常量池:运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。比如 String 类的 intern ()

https://www.cnblogs.com/czwbig/p/11127124.html
https://www.sczyh30.com/posts/Java/jvm-memory/

GC#

Garbage Collection, 垃圾回收。Java 相较于 C++ 的一个主要的区别即使 Java 有垃圾回收机制。一般情况下不需要程序员手动释放内存。
对于垃圾回收的思路主要有两种:

  • 引用计数法:他的思想是给对象一个引用计数器,每当被引用一次就 + 1. 不引用了就 - 1,当计数器为 0 的时候就可以判断当前对象没用,可以被 GC 了。但是这个思路有个严重的缺陷就是无法解决循环引用的问题,A 引用了 B,B 又引用了 A。当这两个对象都没用的时候,GC 是无法判断的。从而造成了内存的浪费。

  • 可达性分析:这个的思路是把一组对象作为根节点,从根节点开始遍历。遍历结束后如果有对象是不可达的就说明这个对象没有被用到可以被 GC。JVM 就是的 GC 就是用了这个思路
    那么那些对象可以座位根节点(GC Rooot)呢?

  • 存活的线程

  • 虚拟机栈中本地变量表中引用所引用的对象。

  • 方法区中静态属性和常量引用的对象。

  • 本地方法栈中 JNI 引用的变量

    You kit—GC roots

Java 中的四种引用 | 强弱软虚#

StrongReference
StrongReference(强引用)是最普通的引用类型,只要强引用存在,GC 就不会进行垃圾回收。

SoftReference
SoftReference(软引用)用来描述一些有用但是非必需的对象。如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,JVM 就会把这个软引用加入到与之关联的引用队列中。软引用可用来实现内存敏感的高速缓存。

WeakReference
WeakReference(弱引用)是一种生命周期比软引用更短的引用。当 GC 扫描启动时,只要扫描到只具有弱引用的对象,无论内存是否够用都会执行 GC,但由于 GC 线程优先级很低,因此并不一定能迅速发现这些弱引用对象。弱引用也可以和一个引用队列联合使用。
WeakReference 在 Android 中用的挺多。

PhantomReference
PhantomReference(虚引用)不同于其余三种引用,虚引用不会影响对象的生命周期,也无法通过虚引用获得对象的一个实例;如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动,它必须和引用队列联合使用。
~ 说实话目前还不了解这么设计的用处,也没有用过~

GC 算法#

GC 算法主要有三种,分别是 MS (标记清除法)、MC (标记整理法)、Copying (复制算法)

  • MS 算法:Marked-Sweep,标记清除算法。很简单,可达性分析对不可达对象进行标记,把要清理然后清除就 OK 了。多用于老年代。这个算法的主要缺点有两个。

    • 标记和清理效率都不高
    • 容易产生内存碎片,如果有大对象进来没有足够内存就会触发 GC
  • MC 算法:Marked-Compact,标记压缩算法,根据可达性分析对不可达对象进行标记后,把存活的对象移至一端 (reallocate),然后将剩余的部分进行回收,这个过程要进行 remapping。多用于老年代。

  • Copying 复制算法:将内存划分为两块,每次只用一块。进行回收的时候把所有生存的对象放到另一块。之后对之前的那块内存整个清理即可。新生代用这种算法。
    当前 JVM 的 GC 一般都是分代收集,几种垃圾回收算法进行组合。
    下面来分析一下 JVM 内存的分代模型:

    由图可以看出,JVM 将内存区域分为新生代,老年代和永久代(也就是方法区,JDK1.8 之后去除了)而新生代又分为一个 Eden 代 和两个 survivor 代。当对象在新生代经历了默认 15 次的 gc 后还存活就放到老年代。大对象也可以直接去老年代。这些都可以配置。那为什么要一个 E 和两个 S 呢,主要原因还是避免产生内存碎片。每次用一个 E 和一个 S。每次进行 GC 的时候把 E 和 S0 的存活对象都放到 S1 中。然后对 E 和 S0 进行清理。接下来 S1 和 S0 相当于逻辑上的互换。这样就避免了内存碎片的产生。HotSpot JVM 的默认比例为 8:2。这样默认空间利用率可达 90%。
    PS:在发生 FULL GC 的时候,意味着 JVM 会安全的暂停所有正在执行的线程(Stop The World),来回收内存空间,在这个时间内,所有除了回收垃圾的线程外,其他有关 JAVA 的程序,代码都会静止,反映到系统上,就会出现系统响应大幅度变慢,卡机等状态。

    《深入理解 Java 虚拟机》- 周志明

GC 的实现–垃圾回收器#

垃圾收集器

  • 新生代收集器:Serial、ParNew、Parallel Scavenage
  • 老年代收集器:Serial old、Parallel Old、CMS
  • 整堆回收:G1
    首先介绍 JVM 的模式有两种 Server (重量)和 Client(轻量)

Serial:单线程,简单高效。Stop the World。使用 Client 模式的 JVM
ParNew:Serial 的多线程版本,使用 Server 模式。并行多线程
Parallel Scavenage:与吞吐量关系密切,故也称为吞吐量优先收集器。该收集器的目标是达到一个可控制的吞吐量。并行多线程。
Serial Old :Serial 老年代版本,M-C 垃圾回收算法。
Parallel Old :Parallel Scavenage 老年代版本。多线程,采用 M-C 垃圾回收算法。注重高吞吐量以及 CPU 资源敏感的场合使用。
CMS 收集器:Concurrent-Marked-Sweep,一种以获取最短回收停顿时间为目标的收集器。 CMS 收集器的内存回收过程是与用户线程一起并发执行的。是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
G1 收集器:Garbage-First 是一款面向服务端应用的垃圾收集器。详情请参阅 Jvm 垃圾回收器(终结篇)

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。