JVM内存结构

JVM内存结构

文章发布于 2021-01-16 14:45:05,最后更新于 2021-01-25 21:15:12

JVM内存调优—基础概念

微信图片_20210116191321.jpg

最近对JVM有很大的兴趣,也看了很多关于JVM的介绍,但是感觉好多文章都讲得不够细致,包括一些知识点,都没有将全面,正好最近买了一本《深入理解JVM虚拟机》就想着边读边做一些笔记把,一方面加深理解,另一方面梳理知识点,方便后续 查阅!

Ⅰ、JVM简介

分为线程私有和线程共享两个部分

线程私有:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈

线程共享:

  • 方法区
  • 直接内存

事实上在JDK不同的版本关于JVM也有不同的划分

JDK1.8之前:

9e89f4b9baee7f5c86cbecec319b1b4.png

JDK1.8:

64fac779aca4c19256a80c13821d745.png

1.1、程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

另外,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

总的来说他有两个功能:

  1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

1.2、Java虚拟机栈

与程序计数器一样,Java 虚拟机栈也是线程私有的,它的生命周期和线程相同,描述的是 Java 方法执行的线程内存模型

每个方法被执行的时候,java虚拟机会同步创建一个栈帧,用来存储局部变量表、操作数栈、动态连接、方法出口等,一个方法执行完毕,就代表一个栈帧在虚拟机栈中从入栈到出栈。

Java 内存可以粗糙的区分为堆内存(Heap)和栈内存 (Stack),其中栈就是现在说的虚拟机栈,或者说是虚拟机栈中局部变量表部分。 (实际上,Java 虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息。)

局部变量表主要存放了编译期可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

方法的调用?

java 栈可用类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。

Java 方法有两种返回方式:

  1. return 语句。
  2. 抛出异常。

不管哪种返回方式都会导致栈帧被弹出。

1.3、本地方法栈

和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

热点知识:

本地方法是由其他语言(如C、C++ 或其他汇编语言)编写,编译成和处理器相关的代码。本地方法保存在动态连接库中,格式是各个平台专用的,运行中的java程序调用本地方法时,虚拟机装载包含这个本地方法的动态库,并调用这个方法

1.4、堆

Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

所有的对象实例以及数组都应当在堆上分配! 《JAVA虚拟机规范》

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

在 JDK 7 版本及JDK 7 版本之前,堆内存被通常被分为下面三部分

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永生代(Permanent Generation)
    image20210111205634766.png

JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。
image20210111205652664.png

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

1.5、方法区

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

方法区也被称为永久代。(对于其他虚拟机来说是不存在的hotspot)

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是 HotSpot 的概念,方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。

JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小

-XX:PermSize=N //方法区 (永久代) 初始大小

-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。

下面是一些常用参数:

-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)

-XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小

为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
  1. 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
  2. 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
  3. 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。

1.6、运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译期生成的各种字面量和符号引用)

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误。

1.7、直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)缓存区(Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据

本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

参数汇总

-Xms20M 最小堆空间

-Xmx20M 最大堆空间

-Xmn10M 新生代空间

-Xss128k 设置栈内存大小

JDK6之前 -XX:PermSize=6M 永久代大小 -XX:MaxPermSize=6M 最大永久大大小

JDK8之后 -XX:MaxMetaspaceSize=6M 最大元空间

-XX:MetaspaceSize=6M 元空间

-XX:MaxDirectMemorySize=10M 直接内存

-XX:SurvivorRatio=8

-XX:+PrintGCDetails

Ⅱ、OutOfMemoryError异常

实战代码如下

/**
 * -XX:+PrintGCDetails
 * -XX:+UseSerialGC
 * -Xms20M
 * -Xmx20M
 * -Xmn10M
 * -XX:SurvivorRatio=8
 */
public class test {
    static class OOMObject{
    }
    public static void main(String[] args) {
      List<OOMObject> list=new ArrayList<OOMObject>();
        while (true){
            list.add(new OOMObject());
        }
    }
}

对于这种常见的内存溢出问题通常首先会使用内存映射分析工具,对Dump出的堆转储快照进行分析。

  1. 首先确认内存中导致OOM的对象是否是必要的(分辨出是内存泄漏还是内存溢出)
  2. 如果内存泄漏,需要查看对象的GC Root引用链,找到对象的引用关系,为何垃圾回收器收集不到。
  3. 根据引用链定位对象创建为止
  4. 如果不是内存泄漏,就应该检查虚拟机的堆参数,与机器内存对比,看看是否还有上调空间。
  1. jmap -dump:format=b,file=/path/heap.bin 进程ID
  2. jmap -dump:live,format=b,file=/path/heap.bin 进程ID

a182d4e4b890fb1f08493789b03f8cda_t

Ⅲ、内存分配和对象

3.1、什么是垃圾回收

Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是 内存中对象的分配与回收。

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap).

从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代

再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

image20210111211221907.png

上图所示的 Eden 区、From Survivor0("From") 区、To Survivor1("To") 区都属于新生代,Old Memory 区属于老年代。

常用参数

-XX:+PrintGCDetails
-Xms20M
-Xmx20M
-Xmn10M
-XX:SurvivorRatio=8
-XX:PretenureSizeThreshold=6M
-XX:MaxTenuringThreshould=   //对象存活时间
-XX:HandlePromotionFailure  //空间内存分配担保

3.2、堆内存中对象分配的基本策略

image20210111211420431.png

  • 优先分配到eden
  • 大对象直接进入老年代 (因为年轻代的垃圾回收次数很多,每次都加载大对象影响性能)
  • 长期存活的对象分配到老年代
  • 空间分配担保
  • 动态对象年龄判断

3.3、对象

如何区分对象是否或者还是已经死,有下列几种算法

3.3.1:引用计数法

在对象中添加一个引用计数器,每当有一个地方使用它,计数器值就加一;当引用失效时,数值就减一;任何时刻,计数器为零的对象就是不可能再被使用的

思考 ?这个算法的弊端?JAVA虚拟机是否 采用的这个算法呢?

答:单纯的引用计数法无法解决对象之间的循环引用问题,而且简单的引用计数法 实现,需要配合大量的额外处理。

案例代码:

public class TestGC {
    public Object instance=null;
    private static final int  _1MB=1024*1024;
    private byte[] bigSize=new byte[2*_1MB];

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

        objA=null;
        objB=null;

        System.gc();
    }
}

效果展示

image20210116123344220.png

分析结果

上面案例说明了JAVA虚拟机默认 并没有使用引用计数法来判断对象 是否会被回收

3.3.2:可达性分析算法

当前商用的程序语言的内存管理都是通过可达性分析算法来判断对象是否存活。

算法思路:通过”GC Roots“的根对象作为起始节点集。从这些节点开始,根据引用关系向下搜索,搜索所走过的路径就是”引用链“,如果某个对象到"GC Roots"没有引用链,那么对象就是不可达的,会被回收。

image20210116135628425.png

2.3.2.1 哪些对象可以做 root

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 本地方法栈中JNI(即一般说的native方法)引用的对象。 
  • 方法区中的静态变量和常量引用的对象。

3.4、引用

上面的两种判断对象是否存货的算法都和引用离不开关系,JDK1.2之后JAVA对引用的概念有了更为明确的定义;

  • 强引用:类似”Object obj=new Object()“这种关系,只要强引用的关系还存在,垃圾回收都不回收被引用的对象!
  • 软引用:描述一些还有用,但非必须的对象。被软引用关联的对象,在系统发生OOM之前,会把这些对象列入回收范围之中,如果这次回收还没有足够的内存,才会抛出OOM。(softReference)
  • 弱引用:弱引用也是用来描述非必需的对象,但他的强度比软引用弱一点,弱引用只会存活到下一次垃圾回收发生为止。当垃圾回收开始工作的时候,无论内存是够用都会回收只被弱引用关联的对象。(weakReference)
  • 虚引用:虚引用,完全不会对生存时间有影响,作用就是为了能在对象被收集器回收时收到一个系统通知。(PhantomReference)

3.5、复活

即使在可达性分析算法中被判定为不可达的对象,也不是”非死不可“的。这时候他们还是处于”缓刑“阶段,真正的宣告一个对象的死亡至少要有两个过程:

  1. 如果对象在可达性分析中发现与GC Roots相连接的引用链,那么会被第一次标记。
  2. 随后会进行一次筛选,条件是对象是否有必要执行finalize()方法,假如没有覆盖finalize方法或者已经调用过finalize方法,那么虚拟机将认为”没有必要执行“。
public class TestGC {
    public static TestGC instance=null;
    public static void main(String[] args) throws InterruptedException {
        instance = new TestGC();
        //对象第一次拯救自己
        instance=null;
        System.gc();
        Thread.sleep(500);
        //finalize方法优先级低,所以 这里 得暂停0.5s等他执行
        if(instance!=null){
            instance.isAlive();
        }else{
            System.out.println("no,I am dead!");
        }
        //对象第二次拯救自己
        instance=null;
        System.gc();
        Thread.sleep(500);
        if(instance!=null){
            instance.isAlive();
        }else{
            System.out.println("no,I am dead!");
        }
    }
  public void isAlive(){
        System.out.println("Yes,I am alive!");
  }

   public void finalize() throws Throwable {
        super.finalize();
       System.out.println("finalize method executed!");
       //把自己赋值给变量,建立引用关系
       TestGC.instance=this;
  }
}

效果展示:

image20210116142640541.png

结束语

关于JVM性能调优这一章讲的基本上都是一些基础的知识,后续我会继续学习垃圾回收算法、垃圾收集器,还有一些性能分析,jvm调优工具使用,等等并且做个总结!
789914f5c6db994593c38068ddb0a74c_t
我是星宇,一个满头黑发,渴望秃头的开发,我们下期见!

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://mxyblogs.club/archives/jvm内存调优-基础概念

Buy me a cup of coffee ☕.