章一 JVM基础【基于JDK8及以上】

本文是JVM系列的开篇,从JVM内存模型切入,系统梳理JVM相关的核心知识点。关于JVM的初级内容,如JVM简介与发展历史、架构概览、Java 字节码解析以及类加载机制等,
我们将在后续的详细介绍中逐步展开。本系列将从内存管理的角度深入解读JVM的工作原理,并逐步涵盖垃圾回收机制、性能调优、多线程与并发模型等内容。


<!-- TOC -->

jvm内存模型

jvm内存模型主要分为:方法区(元空间),堆内存、程序计数器、栈内存、本地方法栈、直接内存

各区域对比说明

区域主要存储内容内存分配位置
程序计数器当前线程执行的字节码指令地址jvm内部
方法区(元空间)方法元数据、jit编译后代码等本地内存
对象实例、静态变量、运行时常量池jvm堆内存
每个线程的局部变量、操作数栈jvm栈内存
  1. 程序计数器:每个线程一个独立的程序计数器,用于存储当前线程执行的字节码地址,如果是native方法,计数器为空
  2. 方法区:方法区中存储类元数据(类名、修饰符、字段、方法描述等)、运行时常量池、方法字节码等数据
  3. 堆内存:应用程序创建的所有对象实例以及JIT编译过程生成的编译代码,是jvm中最大的一块内存区域,使用分代模型
  4. 栈内存:内存线程独享,基本类型的变量以及对象应用存在栈内存中

    • 虚拟机栈:每个线程执行方法时的栈帧,每个栈帧包括局部变量表、操作数栈、动态链接、方法返回地址,方法的调用和结束就是栈帧入栈和出栈的过程
    • 本地方法栈:为本地方法服务的栈帧,即通过JNI调用的c/c++代码

堆内存分配(经典分代收集器)

在不同的Gc中存在各自的内存分配规则,将在后续详细阐述

  1. 内存分配区域,主要分为新生代和老年代

    • 新生代

      • eden区:绝大多数对象优先分配到eden区
      • survivor:从eden区晋升的对象复制到survivor区(分为to和from)
    • 老年代:存放生命周期较长或较大的对象,通常是从survivor晋升的对象
    • 堆外内存:某些场景下可以通过DirectByteBuffer或其他工具分配堆外内存
  2. 内存分配规则

    • 优先分配到eden区,当eden区内存不足,触发Minor Gc,将eden区对象转移到survivor区,survivor区内存不足,对象移动到老年代
    • 大对象,即需要连续内容空间的对象(大数组或大量字段的对象),直接分配到老年代

      • 通过 -XX:PretenureSizeThreshold设置大对象的阈值,大于该值的对象直接分配到老年代
      • 适用于G1之外的垃圾收集器,例如GMS
    • 对象晋升

      • 对象在survivor区经历一定次数的GC后,会晋升到老年代。标记次数可由参数 -XX:MatTenuringThreshold设置,默认值为15

        • 每经过一次Minor Gc,年龄+1
    • 大量分配

      • 当新生代内存不足,jvm将对象直接分配到老年代,此时会增加老年代内存使用率,易触发Full GC
    • 空间分配担保

      • 在新生代发生Minor Gc前,jvm会检查老年代是否有足够空间,如果空间不足会触发Full Gc
        内存分配
  3. 相关JVM参数
参数作用
-Xms/-Xmx设置堆内存的初始大小和最大值
-XX:NewRatio设置新生代和老年代比例
-XX:SurvivorRation设置eden区和Survivor区比例
-XX:PretenureSizeThreshold设置大对象阈值
-XX:MaxTenuringThreshold设置对象晋升年龄阈值
-XX:+UseTLAB/-XX:TLABSize控制TLAB是否启用以及大小

类加载

jvm类加载机制是java虚拟机动态加载类的过程,主要包括类加载、连接、初始化三个阶段

  1. 加载

    • 通过类的全限定名获取类的二进制字节,将字节流代表的静态存储结构转化成方法区的运行时数据结构
    • 类加载器

      • 启动类加载器:BootStrap ClassLoader,加载java.*包
      • 扩展类加载器:Extension ClassLoader,加载lib/ext目录或java.ext.dirs系统属性指定目录中的类
      • 应用程序加载器:Application ClassLoader,加载classPath中的类
      • 自定义加载器:用户继承ClassLoader实现
  2. 连接

    • 验证:确保字节码文件格式正确,保证jvm安全性
    • 准备:为类的静态变量分配内存,并初始化为默认值
    • 解析:将类、方法、字段的符号引用替换为直接引用,例如常量池中的符号引用解析为具体的内存地址
  3. 初始化

    • 初始化阶段对类的静态变量和静态代码快执行显示初始化
    • 执行类的 <clinit>方法,该方法有编译器生成,包含所有静态变量赋值和静态代码块
    • 双亲委派机制

      • 加载类时,先委派给父类加载器进行加载,当无父类加载器时,才由当前加载器尝试加载
      • 该机制可以保证java的核心类库不会被自定义加载器替代,确保jvm的安全性和稳定性
    • 惰性加载

      • 类加载时按需进行,在使用时才加载
      • 类的静态成员,只有在首次访问时才会触发加载
  4. 初始化过程

    • 静态变量初始化,jvm在准备阶段为类的所有静态变量分配内存,并初始化默认值,在初始化阶段,会根据代码中指定的值重新赋值

       public class MyClass {
       static int x = 10; // 静态变量赋值
      }

      准备阶段,x=0,初始化阶段,x=10

    • 静态代码块只会在类加载时执行一次

       public class MyClass {
       static {
         System.out.println("Static Block 1");
       }
       static int x = 10;
       static {
         System.out.println("Static Block 2");
       }
      }

      输出结果:1、Static Block 1 2、初始化x=10 3、输出Static Block 2

    • 父类初始化优先于子类

      class Parent {
       static {
         System.out.println("Parent initialized");
       }
      }
      
      class Child extends Parent {
       static {
         System.out.println("Child initialized");
       }
      }
      
      public class Test {
       public static void main(String[] args) {
         Child child = new Child();
       }
      }

      输出:
      Parent initialized
      Child initialized

    • clinit方法:jvm会为类生成特殊的< clinit >方法,包含静态变量的复制操作和静态代码块的内容

逃逸分析

逃逸分析:逃逸分析是jvm的一种优化技术,用于确定一个对象的作用范围,判断该对象是否逃逸出某个特性的范围,如方法或线程,基于逃逸分析的结果,jvm可以对代码进行多种优化,比如栈上分配、标量替换、同步省略等

  • 方法逃逸:如果一个对象被一个方法外的其他方法引用,那么该对象即发生逃逸
      public Object createObject() {
         return new Object(); // 对象返回给调用方,发生方法逃逸
   } 
  • 线程逃逸
   public void threadEscape() {
      new Thread(() -> {
        System.out.println(this); // 对象在另一个线程中被引用,发生线程逃逸
      }).start();
   }
  1. 逃逸分析作用

    • 栈上分配:如果一个对象没有发生逃逸,那么对象可以直接分配在栈上,而不是堆上,可以避免垃圾回收的开销
    • 锁消除:如果一个加锁的对象没有发生逃逸分析,即不会出现锁竞争情况,jvm可以安全的删除锁操作
    • 标量替换:如果对象没有发生逃逸,并且它的内容可以被拆解为多个字段,则可以通过标量替换避免对象创建
  2. 如果启用逃逸分析,jvm中逃逸分析默认开启,相关jvm参数如下

    • -XX:+DoEscapeAnalysis:开启逃逸分析
    • -XX:+PrintEscapeAnalysis:打印逃逸分析的结果
    • -XX:+EliminateLocks:启用锁消除
    • -XX:+EliminateAllocations:启用分配优先
  3. 逃逸分析风险:当未发生逃逸分析的对象过大时,可能更容易触发栈溢出

    如下代码,未发生逃逸分析,且需要分配较大内存,极易发生栈溢出

    public class StackOverflowExample {
    
        public void recursiveMethod() {
            // 创建一个非常大的对象,但它没有逃逸出方法范围
            int[] largeArray = new int[1024 * 1024]; // ~4MB的栈空间
            recursiveMethod(); // 递归调用
        }
    
        public static void main(String[] args) {
            StackOverflowExample example = new StackOverflowExample();
            example.recursiveMethod();
        }
       }
  4. 避免栈溢出

    • 避免在方法中分配过大的局部对象,如果方法中必须分配大对象,需要设置方法返回值
    • 递归方法中,设置结束条件,并且需要避免递归深度,最好不要超过5层
    • 通过jvm参数设置合理栈大小,例如:-Xss2m

树荫下的影
3 声望0 粉丝

下一篇 »
JVM调优篇