阅读 498

再看 JVM(1)

那些年翻来覆去折腾 JVM

这不是我第一次学习 JVM 的知识了,从开始学习 java 语法开始,老师就告诉我们堆啊、栈啊的,那会真是不理解啊,狗捉耗子多管闲事,知道怎么写代码不就行了嘛~

后来逐渐的知道了,java 内存分配的意思,不了解关于 java 内存的部分,你都不知道你的变量什么时候就不是你想要的那个值了

因此专门去看了 java 的内存分配,为了性能优化又去看了GC 垃圾回收,这其中反复看了好几次

这次应该是我第5次看 JVM 的内容,也是第2次写 JVM 的博客,上次那篇已经作废了。以前即便学习 内存分配GC 垃圾回收 那也是单独的看,从没有站在 JVM 总体设计的角度一起看思考,这次站在 JVM 总体设计的角度,我发现了更多的知识点,比如:类的加载机制,也发展其实诸如这些其实都是紧密相互关联的,只要我们能理解 JVM 设计的初衷,理解这些其实也没有多大难度了,单个内容理解起来有的真的挺费劲的

学习资料

web 上的博文基本都是说 JVM 单个知识点的,随着 java 版本的变迁,其中有太多的错误,让我们理解起来既费劲,也搞不明白

JVM 的书倒是有基本不错的,但是阅读门槛比较高,强读很多都理解不了

这里我推荐B站尚硅谷的JVM视频,讲的非常好,不光有理论,还有严谨的推导过程,使用转用工具一步步验证,而不是胡说白咧、胡讲,很多内容也是引用自学习JVM的经典书籍:《深入理解 JVM 虚拟机》

这一个视频+这本书,学习 JVM 的不二法宝,大家不用再找其他资料了,你想知道的,你想不到的这里都有,尤其是视频,即便小白都能看的懂,感谢尚硅谷。更难能可贵的是该视频里有很多分析工具和思路,这点是非常值得学习的东西,甚至比JVM本身更值得学习

另外在学习过程中,有一些连带的点很重要,面试文的很多的,但是不太适合放在本文的,本文也不能写的太长了,就另开了JVM面试的文章,因为关联性很大,希望大家都去看看,能对JVM的理解更上一个台阶

需要自定义类加载器的请看这里的内容:

简单说下 java 发展历程


java 最重要的3个虚拟机:hotspot,JRockit,J9 IBM的

10年 Oracle 收购了 REA 之后,致力于融合 hotspot 和 JRockit 这2个知名的虚拟机,但是2者之间差异太大,hotspot 可以借鉴的比较少,这个成果在 JDK 8 中得以体现,JDK 8的虚拟机虽然还叫 hotspot,但这个 hotspot 是大量借鉴 JRockit 技术之后的成果了,不可同日而语

JDK 11时,革命性的垃圾回收器 ZGC 出来了,目前 ZGC 还是实验性的,但是实验来看性能远超 G1,虽然 G1 垃圾回收器还是主流,但是未来一定会被 ZGC 替代

JDK 11 开始,Oracle 每3个版本发布一个长期稳定支持版本,其他版本都支持半年,并且更新内容有限,只有大版本才有大的变化。但是也是从11开始,Oracle 每次都发布2个版本,一个免费 OpenJDK,一个商业收费 OracleJDK

所以 JDK8 是目前使用最多的版本,也是目前我们学习的基准,另外阿里巴巴有自己的虚拟机 Taobao JVM,这里不得不赞一个,阿里真是国内互联网的基石啊

JVM 我们学习什么呢

学习 JVM,我们当然要学习 JVM 的3大组成部分了:类加载器运行时数据区执行引擎

但是我们之前都是每个点学每个点的,从没有站在 JVM 总体的角度上串起来看,这就是这次我要展示给大家的,从总体上看,从总体上理解,其实每个点都是相互关联的

1. JVM 和硬件紧密相联,站在全局的角度去理解 JVM

任何代码都是跑在硬件上的,我们之前学习 JVM 的内容都是学习的 API,从没有考虑硬件上的内容,其实我们若是把 JVM 和硬件上的关联搞清楚,很多晦涩难懂的知识点迎刃而解。比如多线程,难倒不是因为内存的原因而设计的吗,难倒 java 的多线程不是 JVM 决定、管理的嘛,归根结底,多线程就是内存、字节码、指令的运作

java 相比 c 多了什么,多的就是 JVM,就是今天我们研究的东西。java 里我们只要关系逻辑代码怎么写就行了,内存分配不用我们管,内存回收不用我们管,和操作系统的交互不用我们管。经典的 Thread 就是 JVM 代我们去和操作系统内核交互

C++ 需要我们自己分配内存,自己回收,你 C++ 要是技术很好,内存可以使用的非常高效,也不会出现涌余。但要是你技术不高的话,内存可能会非常混乱,从语言发展的角度说自动管理也是大的趋势

有人说:JVM 已经是一个独立的虚拟的计算机了,一台新的机器只要安转了 java 运行环境,立马就行跑 java 代码,但是 C 行吗... 在C 里面我们要自己操作内存,要自己和操作系统交互

这就是为什么 JVM 在现在越来越受欢迎的原理,封装了底层操作,让我们专心于逻辑,这点也是高级语法发展的趋势,就算不是 JVM,也会有自己的 VM,让代码越来越简单

不光如此,JVM 不仅仅是对开发者屏蔽了硬件和操作系统层面的操作,JVM 更是有自己的指令系统:字节码,就是这么个东西,我们知道 CPU 硬件实际执行的是 010101 这样的二进制指令代码。而 JVM 有自己的指令代码,就是编译完成的 .class 里面的内容

这里是一个反编译出来的方法,大家看看用自己码是怎么写的

  public void speak();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=3, args_size=1
         0: bipush        10
         2: istore_1
         3: bipush        20
         5: istore_2
         6: return
      LineNumberTable:
        line 12: 0
        line 13: 3
        line 15: 6
复制代码

bipush 10istore_1 这些大家认识吗,看着是不是和汇编多少优点像啊,这就是 JVM 自己设计的,专属于自己的指令集。所以说 JVM 更像是一个虚拟的计算机,只要有一个硬件设备,上面安装有一个内核,JVM 就能顺利的运行,甚至不需要完整的操作系统支持

什么是虚拟机:就是一套用来执行特定虚拟指令的软件。比如要在 mac 上跑 wins 就需要一个虚拟机,要不 mac 怎么认识 X86 指令呢...

2. JVM 已经超脱 java 了

如果说 java 是跨平台的语言:

那么 JVM 就是跨语言的平台:

越来越多的语言选择运行在 JVM 环境上,不管这个语言怎么写的,主要该语言的编译器把代码最终编译成 .class 标准字节码文件,那么就都能在 JVM 上运行。像上图中的,这些永远都可以在 JVM 上运行

JVM 已经变成一个生态了,这不能不让我们去思考,我觉得大家看到这里都思考一下是有好处的,感慨下,这就是一种趋势

总体来说JVM就一句话:从软件层面屏蔽不同操作系统在底层硬件个指令上的区别,也包括顶层的高级语言

3. 理解学习 JVM 的好处

这是 java 程序的结构,JVM 提供最底层运行支持,使用 java 提供的 API 开发了很多框架,我们使用这些框架开发出最终的服务,app等

JVM 是最终承载我们代码的地方,你的服务运行的好不好,卡不卡不都看 JVM 的反馈嘛。单从性能优化的角度看,我们都得对最底层的知识体系有足够了解

懂得 JVM 内存结构,工作机制,是设计高扩展性应用和优化性能的基础,阻碍程序运行的永远是我们对硬件使用的效率,对硬件使用效率的高低决定了我们程序执行的效率

下面这些问题我想大家都会遇到吧:

  • 线上系统突然卡死,OOM
  • 内存抖动
  • 线上 GC 问题,无从下手
  • 新项目上线,JVM 参数配置一脸懵逼
  • 面试 JVM 直接被拍晕在地上,JVM 如何调优,如何解决 GC,OOM

JVM 玩不转别想吧上面这些搞顺溜了...

就算是不是后台服务的,你搞 android 或者其他就没有 内存抖动 的问题啦,不可能的,只要你语言用的 java 或者跑在 JVM 上,这 JVM 都是你逃不过去的

了解 JVM

知道了这些点之后,有助于我们理解后面 JVM 的内容

1. 再次理解什么是JVM

这是摘抄过来的一句话,不用再解释了,大家仔细揣摩

虚拟机的概念是相对于物理机而言的,这两种机器都有执行代码的能力。
物理机的执行引擎是直接建立在硬件处理器、物理寄存器、指令集和操作系统层面的
而虚拟机的执行引擎是自己实现的,因此可以自定义指令集和执行引擎的结构体系
而且可以执行那些不能被硬件直接支持的指令

在不同的“虚拟机”实现里面,执行引擎在执行JAVA代码的时候有两种方式:
1. 解析实行(通过解释器执行)
2. 和编译执行(通过即时编译器编译成本地代码执行)
复制代码

2. Java进程之间以及跟JVM关系

java程序是跑在JVM上的,严格来讲,是跑在JVM实例上的,一个JVM实例其实就是JVM跑起来的进程,二者合起来称之为一个JAVA进程

各个JVM实例之间是相互隔离的

  • 一个进程可以拥有多个线程
  • 一个程序可以有多个进程(多次执行,也可以没有进程,不执行)
  • 一台机器上可以有多个JVM实例(也可以没有JVM实例)
  • 进程是指一段正在执行的程序
  • 线程是程序执行的最小单位
  • 通过多次执行一个程序可以有多个进程,通过调用一个进程可以有多个程序

程序运行时,会首先建立一个JVM实例----------所以说,JVM实例是多个的,每个运行的程序对应一个JVM实例。每个java程序都运行在一个单独的JVM实例上,(new创建实例,存放在堆空间),所以说一个java程序的多个线程,共享堆内存

总的来说,操作系统的执行单元是进程,每一个JVM实例就是一个进程,而在该实例上运行的主程序是一个主线程(可以看成一个轻量级的进程),该程序下还存在很多线程

还有一个 JVM 实例对应一个 Runtime 对象,我们可以从该 Runtime 对象中获取一些参数,比如堆内存的初始值和最大值

3. java 也是起源自小程序

有意思的是,java 最早是为了在 IE3 浏览器中执行 java applets,原来早先 java 也是小程序出身,但是谁让后来 java 火了呢...

4. Taobao JVM

阿里很NB,自己基于 OpenJDK 深度定制了自己的 alibabaJDK,并且定制了自己的 Taobao JVM,很厉害的

其特点:

  1. 提出了 GCIH 技术,把生命周期较长的对象放到堆外了,提高了 GC 效率,降低了 GC 频率
  2. GCIH 中的对象可以在多个 JVM 实例中相互共享
  3. 使用 crc32 指令降低 JNI 开销
  4. 针对大数据场景的 ZenGC

缺点是高度依赖 Intel cpu,目前在天猫,淘宝上应用,全面替代 Oracle 官方 JVM

5. JVM 和线程

线程是一个程序里的运行单元,JVM 允许一个应用有多个线程并行执行

在 Hotspot 虚拟机中,每个线程都与操作系统的本地线程直接映射。当一个 java 线程准备好执行之后,一个操作系统的本地线程也会同时被创建。java 线程终止后,本地线程也会被回收

操作修通负责把所有线程安排哦调度到任何一个可用的 CPU 上去执行,一旦本地线程初始化完成,就会调用 java 线程中的 run()

一个 JVM 实例里有很多后台线程:

  • 虚拟机线程: 这种线程的操作是需要JVM达到安全点才会出现,这种线程的执行类型包括"stop-the-wrold"的垃圾收集,线程栈回收,线程挂起,偏向锁撤销
  • 周期任务线程: 这种线程是时间周期事件的体现,比如中断
  • GC线程
  • 编译线程: 把字节码编译成本地代码
  • 信号调度线程: 这种线程接收信号并发送给JVM

JVM 命令和运行参数调整

IDE 配置 JVM 参数

只需要在 VM options 里面写设置即可,比如:

-XX:MetaspaceSize=100m
复制代码

MetaspaceSize 是方法区的大小,这样写就行,想改哪个就用对应的英文单词好了

JVM 参数简写问题

后面大家会看到诸如:-Xms 这样的JVM参数,一看就知道是简写,其实 -Xms = -XX:InitialHeapSize,大家知道就行,对照着就知道了,别2个都碰到了不知道啥意思

jps 命令

可以查看进程信息

  • jps: 打印所有进程
➜  ~ jps
71187 Jps
70867 GradleDaemon
70814
复制代码
  • jps -l: 输出完整package整路径,android 进程也能打印出来,但是仅限于自己安装的 app
➜  ~ jps -l
70867 org.gradle.launcher.daemon.bootstrap.GradleDaemon
71193 sun.tools.jps.Jps
70814
复制代码

jinfo 命令

可以打印出想看的JVM参数信息,想看那个参数后面跟英文单词和进程ID就行啦

// 打印信息,74290 是进程ID,可以用上面 jps -l 命令查看
➜  ~ jinfo -flag MetaspaceSize 74290
// JVM 配置
-XX:MetaspaceSize=21807104
复制代码

print 指令

  • -XX:+PrintGCDetails VM options 配置项,可以在日志里面把堆栈信息打印出来,挺有用的,GC信息也会被打印出来
  • -XX:+PrintFlagsInitial JVM 所有参数默认值
  • -XX:+PrintFlagsFinal JVM 所有参数最终值
  • -XX:+PrintGC 是PrintGCDetails的简化版,GC信息少了一些,也没有最后的内存信息显示了
  • -XX:PrintStringTableStatistics 打印字符串常量池信息
// 堆内存
Heap

 // 年轻代
 PSYoungGen      total 38400K, used 4663K [0x0000000795580000, 0x0000000798000000, 0x00000007c0000000)
  eden space 33280K, 14% used [0x0000000795580000,0x0000000795a0dc88,0x0000000797600000)
  from space 5120K, 0% used [0x0000000797b00000,0x0000000797b00000,0x0000000798000000)
  to   space 5120K, 0% used [0x0000000797600000,0x0000000797600000,0x0000000797b00000)
  
 // 老年代 
 ParOldGen       total 87552K, used 61440K [0x0000000740000000, 0x0000000745580000, 0x0000000795580000)
  object space 87552K, 70% used [0x0000000740000000,0x0000000743c00010,0x0000000745580000)
  
 // 元空间 
 Metaspace       used 3387K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 376K, capacity 388K, committed 512K, reserved 1048576K
复制代码

GC信息:

方法区参数

基本就4个参数:

  • MetaspaceSize - 方法区大小
  • MaxMetaspaceSize - 方法区最大值,这个数代码里是无限,但实际上不能超过物理内存最大值
  • MinMetaspaceFreeRatio - 在GC之后,最小的Metaspace剩余空间容量的百分比
  • MaxMetaspaceFreeRatio ...
  • -XX:MetaspaceSize=100m - VM options 设置写法

MaxMetaspaceSize 一般我们不动,这个默认是无穷大数,我们一般都会把 MetaspaceSize 调大一点,避免因为 MetaspaceSize 过小造成的 FullGC

堆内存参数

  • -XX:UseTLAB - TLAB 线程专属空间大小
  • Xmx - 堆内存最大值,默认=物理内存的 1/4
  • Xms500m - VM options 这么写
  • -XX:NewRatio=2 - 新生代老年代比例,2的意思是新生代是1占总数的1/3,老年代代是2占总数的2/3,一般我们不改这个参数,因为新生代小了,意味这GC回收频率就要高了
  • -XX:SurvivorRatio=8 - Eden、S0、S1 的比例,8的意思 Eden 是8占总数的8/10,S0是1占总数的1/10,S1是1占总数的1/10
  • Xmn - 新生代最大值,一般不动,一般都用比例,这个写了比例就不算数了
  • -XX:+UseAdaptiveSizePolicy - 自适应内存分配策略,-号是取消设置,+号是采用设置,这个其实不起作用的...
  • jinfo -flag NewRatio 进程ID - 打印新生代老年代比例
  • jinfo -flag SurvivorRatio 进程ID - 打印新生代内比例

栈内存参数

  • -Xss - 栈内存值,只有这个一个参数,可以理解为最大值
  • -Xss900k - VM options 设置写法

GC参数

  • -XX:MaxTenuringThreshold=15 老年代阀值
  • -XX:PretenureSizeThreshold=600k 单个对象大小大于这个数直接进入老年代

JVM优化参数

  • -XX:+DoEscapeAnalysis 启动栈上优化
  • -XX:+EliminateAllocations 启动标量替换
  • -XX:PrintEscapeAnalysis 打印逃逸分析结果
  • -XX:PrintStringTableStatistics 打印字符串常量池信息
  • -XX:StringTableSize=10086 字符串常量池大小
  • 栈上优化和标量替换必须同时设置才能生效

TLAB参数

  • -XX:UseTLAB 开启TLAB,默认是打开的
  • -XX:+TLABSize
  • -XX:TLABWasteTargetPercent=1 TLAB 占 Eden 的比例
  • -XX:TLABRefillWasteFraction
  • -XX:+PrintTLAB

JVM 生命周期

JVM 也是有生命周期的,上文说到我们可以把进程看成一个JVM实例

JVM 生命周期:

  • 启动: JVM启动时会按照其配置要求,申请一块内存,并根据JVM规范和实现将内存划分为几个区域。然后创建出引导类加载器(Bootstrap Classloader)实例,引导类加载器是使用C++语言实现的,负责加载JVM虚拟机运行时所需的基本系统级别的类,如java.lang.String,java.lang.Object等等。然后加载我们写有main方法入口的那个类,执行 main 函数
  • 运行: 就是执行 main 函数,什么时候 main 函数结束了,JVM 也就完结了
  • 退出: 退出护着异常退出,用户线程完全退出了,jvm示例结束生命周期
// 一般 java 里面我们退出进程就是这2个方法
// System.exit 其实也是调的 Runtime,我们跟进去看看

System.exit(0);
Runtime.getRuntime().exit(0);

------------------------------------------------------------------

// System.exit(0)
public static void exit(int status) {
   Runtime.getRuntime().exit(status);
}

// Runtime.getRuntime().exit(0)
public void exit(int status) {
    // Make sure we don't try this several times
    synchronized(this) {
        if (!shuttingDown) {
            shuttingDown = true;
            ........
            // Get out of here finally...
            nativeExit(status);
            }
        }
    }

// nativeExit 最终是一个本地方法
private static native void nativeExit(int code);

复制代码

可能大家对 Runtime 不熟悉,Runtime 是什么呢,就是整个运行时数据区

红框里框起来的就是 Runtime

JVM 整体结构

这里我们以 JDK 为准,以 Hotspot 虚拟机为主

图片来源于:鲁班学院-子牙老师

JVM 的3大组成部分:类加载器运行时数据区执行引擎

下文我会按照 JVM 最佳学习顺序来逐个介绍:类加载器->方法区->内存结构->GC->执行引擎

在上图里大家可以看的很清楚了,这是我能找到的 JVM 最准确、全面的一张结构图了,大家以后以这个为准吧

运行时数据区 这个一向是大家理解的重点,这里有一点其实很多人搞不清楚

线程独有的区域包括:虚拟机栈、本地方法栈、程序计数器 这3个,这3个区域是线程间不可见的,只有自己所在线程可以访问

更详细一点的是这张图:

有句话这么说的:栈管运行,堆管存储,所以堆内存很大,自然要放在物流内存中,也就是内存条里

类加载器 这个很重要的,好多黑科技都有使用到类加载器手动new对象,我们要对这块有清晰的了解才行,虽然 android 有自己的 DexClassLoader,但是也是以 java 的类加载器位基础的,学了不吃亏

执行引擎 包括3部分:解释器,JIT 即时编译器,GC 垃圾回收器3部分组成

java 默认是逐行解释的,运行时,运行到那行字节码了,解释器就去执行该行自己码,字节码怎么执行呢,很简单,没一个字节码指令对应一个 C++ 的方法,JVM 整体都是用 C++ 写的,所以最终字节码都是转换成 C++ 代码去执行

从 OpenJDK cpp 里可以找到执行器的源码,java_executor.cpp 就是

很清楚吧,switch、case,每个字节码指令都对应一个或者多个 C 的方法

IT 即时编译器 是 ART 虚拟机新加入的特性,也是目前 VM 发展的趋势,很多 VM 也加入了 JIT 这个特性,JIT 干的事就是记录并判断热点代码,正常流程解释器解释自己码要吧相关参数带入到相应的C方法路面去执行,会C方法进一步翻译成汇编语言才能给CPU硬件去执行

JIT 就是把热点代码提前编译成汇编代码,可以直接运行,比解释器省了一部操作,这样可以提高CPU执行效率

JIT 对于热点代码编译成机器码之后是缓存在方法区的

从程序共享角度来看内存划分

  • 堆内存: java heap space,满了会抛出OOM异常
  • 元空间: Metaspace ,满了一样会抛出OOM异常
  • 栈空间: Satck,满了一样也会抛出OOM异常

类加载系统

不管我们在 IDE 里面代码写的如何飞起,编译之后也仅仅是冷冷的一个class文件,躺在硬盘里。但是电脑最终是要运行的,靠的是内存。类加载干的就是读取硬盘里面的class文件,转换成可以在内存中,提供给计算机运行、执行程序的类信息(DNA元数据模板)

类信息保存在方法区里面,方法区在本地内存,但是在JVM的堆内存也会跟着生成一个class对象,这个就是我们反射用到的 class 对象,详细后面会说

总体来说class文件从硬盘加载到内存并可以运行,要经历3个大的步奏:

  • 加载
    • 引导类加载器
    • 扩展类加载器
    • 系统类加载器
  • 链接
    • 验证验证
    • 准备
    • 解析
  • 初始化

类加载器系统和方法区紧密相联,毕竟类加载出来的东西是放在方法区的,但是这其中还是又很多讲头的。字节码、常量池、运行时常量池、符号引用转直接引用我都放在后面方法区那部分了,大家像了解请转到后面那里

1. 加载

通过类的全限定名获取二进制字节流,将二进制字节流转换成方法区中的运行时数据结构对象,并在内存中生成对应的Java.lang.class对象

加载类的方式其实很多,大家一定要清楚,黑科技都是借助这个的:

  • 从本地系统直接加载
  • 网络获取,场景:web Applet
  • 从 zip、jar、war 等压缩包中读取
  • 运行时动态生成,比如:动态代理技术
  • 由其他文件生成,比如:JSP 应用
  • 从数据库中获取 class 文件
  • 从加密文件中获取

大家一定要清除啊,android 的黑科技那个没用带这个呢...

2. 链接

链接里面3个小的步奏:验证、准备、解析

  • 校验: 检查导入类或接口的二进制数据的正确性:(文件格式验证,元数据验证,字节码验证,符号引用验证)
  • 准备: 给类的静态变量分配并初始化存储空间,既然都分配属性的内存空间了,那么肯定就有对象了,所以加载那一步在方法区创建出运行时数据结构对象肯定是没错的,要不到这里解释不了。我曾经对何时生成方法区数据对象产生怀疑
  • 解析: 将常量池中的符号引用转成直接引用

3. 初始化

类加载时的初始化方法可不是默认的构造方法啊,构造方法是位对象服务的,类的初始化方法是位类服务的

JVM编译器在编译时会收集类静态变量赋值操作和静态代码块的操作,把这2者合并成一个方法:clinit(),注意这个方法是C++的,对我们不可见。若类没有静态属性,也没有静态代码块那就就没有这个 clinit() 啦

clinit() 方法中代码执行的顺序是按照代码书写顺序来的,谁写在前面,谁就在前面的

比如:

public class Max {
    static {
        age = 100;
    }
    public static int age = 10; 
}
复制代码

这个 age 最后赋值结果是10,谁让static声明在后呢,那age肯定就是10了,具体的分析请看:JVM 面试题【中级】,这里写的很清楚,答案都在字节码里

另外 clinit() 只会执行一次,也就是在类首次被加载的时候执行,所以JVM对于clinit()方法是加锁的。这个锁是谁呢,就是该类方法区类元信息在JVM堆内存中的映射class对象,class也是一个对象,也有自己的对象锁,这里用的就是这个锁,该锁最常见的应用就是经典的双判断单例写法了

另外还要注意,静态代码块里面不要写死循环,要不其他线程在同时加载该类型时会一直阻塞在 clinit 的

比如:

public class Dog {
    static {
        while (true) {

        }
    }
}

public class Max {

    public static void main(String[] args) {

       Runnable r = new Runnable() {
           @Override
           public void run() {
               Dog dog = new Dog();
           }
       };

       Thread t1 = new Thread(r);
       Thread t2 = new Thread(r);

       t1.start();
       t2.start();

    }
}
复制代码

对于 t1,t2 来说,不管谁先加载 Dog 类,都会让对方一直阻塞在 Dog 的初始化函数这里没发往下执行,所以 static{} 静态代码块里我们不要写耗时操作

clinit() 函数在执行时会优先执行父类的 clinit() 函数

4. 初始化方法执行时机

java 类是我们什么时候用,什么时候才加载,这一点大家最好心里有数,有时候有些 BUG 就是因为类虽然加载了但是初始化方法没有自行,你认为在那个时刻类肯定会初始化,但实际上她没有

类的使用可以分主动使用、被动使用,主动使用时会初始化该类,被动使用时不会初始化该类。类加载的3部中,初始化方法不是必须顺着加载、链接这2部执行完后执行的,而是可以自由决定什么时候用

类主动使用的情况:

  • 通过new关键字、反射、clone、反序列化机制实例化对象
  • 调用类的静态方法时
  • 使用类的静态字段或对其赋值时
  • 通过反射调用类的方法时
  • 初始化该类的子类时(初始化子类前其父类必须已经被初始化)
  • JVM启动时被标记为启动类的类(简单理解为具有main方法的类)

5. 类加载器介绍

上面说过类加载可以分3分个大的步奏,在代码上全靠系统提供的类加载来完成,不光涉及java部分,更是涉及C++部分

ClassLoader 是所有类加载的抽象基类,最终返回堆内存种的class对象

public abstract class ClassLoader{
    
    private final ClassLoader parent;
    
    protected Class<?> loadClass(String name, boolean resolve){
        ......
    }
}
复制代码

每个 ClassLoader 都有自己的自己的上一级 ClassLoader,也就是父 ClassLoader,这里在双亲委派机制时会说

实际上系统类加载器有3种,分别加载不同范围的类:

  • BootStrapClassLoader: 引导类加载器,用C++语言写的,它是在Java虚拟机启动后初始化的,可以理解为JVM的一部分。加载java系统核心类库,加载JDK目录下:/jre/librt.jar、resources.jar、charsets.jar的类,为了安全起见,BootStrapClassLoader 只加载包名以:java、javax、sun开头的了类。BootStrapClassLoader 完全由JVM自己控制,我们不仅控制不了,甚至都不可见,在JAVA层面即便拿到 BootStrapClassLoader 的实例,对我们来说也是一个null。BootStrapClassLoader 没有父加载器,BootStrapClassLoader是C++实现的,不可能再走一个java的父类了
  • EtxClassLoader: 扩展类加载器,是java级别的了,是我们可以获取的到的了。注意其父加载器是引导类加载器,加载JDK:/jre/lib/ext中的类(扩展目录),
  • AppClassLoader: 系统类加载器,父加载器是扩展类加载器,加载所有非java核心类库,简单的说就是不是java官方写的代码,比如我们自己,第三方开源类库都是由她加载。

引导类加载器加载目录:

// 系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2

// 扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@27716f4

// 引导类加载器,java.lang.string 已经是java核心类库范围了
ClassLoader bootStrapClassLoader = String.class.getClassLoader();
System.out.println(bootStrapClassLoader);//null
复制代码

获取类加载器的几种方式:

// 通过class获取类加载
ClassLoader classLoader1 = new Dog().getClass().getClassLoader();
ClassLoader classLoader2 = Class.forName("com.bbb.xxx").getClassLoader();

// 通过线程上下文获取类加载,拿到的是系统类加载器
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();

// 直接获取系统类加载器的单例
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
复制代码

ExtClassLoader 和 AppClassLoader 都是在 Launcher 中初始化的,并将 AppClassLoader 设置为线程上下文类加载器。 ExtClassLoader 和 AppClassLoader 都继承自 URLClassLoader ,而最终的父类则为 ClassLoader

Launcher
public Launcher() {
ExtClassLoader localExtClassLoader;
try {
// 扩展类加载器
localExtClassLoader = ExtClassLoader.getExtClassLoader();
} catch (IOException localIOException1) {
throw new InternalError("Could not create extension class loader", localIOException1);
}
try {
// 应用类加载器
this.loader = AppClassLoader.getAppClassLoader(localExtClassLoader);
} catch (IOException localIOException2) {
throw new InternalError("Could not create application class loader", localIOException2);
}
// 设置AppClassLoader为线程上下文类加载器
Thread.currentThread().setContextClassLoader(this.loader);
// ...

static class ExtClassLoader extends java.net.URLClassLoader
static class AppClassLoader extends java.net.URLClassLoader
}
复制代码

6. 自定义类加载器

当然类加载器也可以自定义的

一般这几种情况下会考虑自定义类加载器:

  • 防止源码泄露 - 对字节码加密,类加载时解密,预防反编译篡改
  • 扩展加载源 - 插件化,热修复
  • 修改类的加载方法
  • 隔离加载类- 中间件,中间件和应用模块是隔离的,把类加载到不同环境当中,相互之间不冲突,防止不同依赖之间包名类名相同的类的冲突

我们可以选择继承 ClassLoader 类,重写 findClass() 方法返回目标 class 对象,实际上需要我们自己实现IO流,从磁盘加载class文件到内存生成对应的 bute[] 字节数组,之前我们要是对字节码加密了,那么这个过程我们可以进行解密操作,然后使用使用 ClassLoader 自带的 defineClass() 方法把字节数组转换成 class 对象并返回

    class MyClassload extends ClassLoader {

        @Override
        protected Class<?> findClass(String name) throws ClassNotFoundException {

            byte[] bytes = getCustomeClass(name);

            Class<?> aClass = defineClass(name, bytes, 0, bytes.length);

            return aClass;
        }

        public byte[] getCustomeClass(String name) {
            return null;
        }

    }
复制代码

还有更多我就不详细写了,有需要的请自行 google

7. 双亲委派机制

我们先回国头来再看一遍 ClassLoader 的设计:

public abstract class ClassLoader{
    
    private final ClassLoader parent;
    
    protected Class<?> loadClass(String name, boolean resolve){
        ......
    }
}
复制代码

ClassLoader 对象设计有 parent 父加载器,大家看着像不像链表。链表的next指向下一个,ClassLoader parent 这里上一层级

类加载加载机制中默认不会直接由自己加载,会先用自己的父加载器 parent 去加载,父加载器加载不到再自己加载

JVM 3级类加载器,每一级都有自己能加载类的范围,类加载器一级一级提交给父加载器去加载,每一级类加载在碰到自己能加载的类时,没加载过的会去加载,加载过的会返回已经加载的class对象给下一级

看看 ClassLoader.loadClass() 方法代码:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        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.
                    c = findClass(name);
                }
            }
            return c;
    }
复制代码

这个就叫做:双亲委派机制,为啥叫双亲,因为系统类加载器上面就2级类加载器

java 核心类库有访问权限限制,类加载器在发现允许加载范围之外的类加载的加载请求之后,会直接报错的。这个判断一般都是用包名来判断的,比如你自己搞了一个 String 类,包名还是 java.lang,那引导类加载器在处理这个加载请求时会直接报错,这种报错机制就叫做沙箱安全机制

沙箱安全机制有个典型例子:360沙箱隔离,比如U盘程序只在360认为隔离出来的沙箱内运行,以保护沙箱外的系统不受可能的病毒污染

双亲委派机制的目的是为了保证安全,防止核心 API 被篡改

方法区

1. 基本介绍

方法区这个名称是 JVM 规范对管理 class 类信息的内存区域的称谓,大家可以看成是一个接口,声明出方法来了,但是具体怎么实现还得看具体的实现类不是

JDK1.6 之前 hotspot 虚拟机关于方法区的实现叫永久带,和堆内存一样都在JVM实例进程内,OOM的问题比较严重,GC也会频繁扫描这个区域,性能比较低

JDK1.8 hotspot 虚拟机换了个方法区的实现叫元空间,把类信息从JVM实例内存区域移出来,放到本地内存 native memory 中去了,这样 OOM 风险小多了,再也不怕加载的类太多爆 OOM 了,GC 扫描的频率也降低了

JVM 各部分之间联系很紧密,方法区承载类加载器加载、解析到内存中的字节码文件,记录类的元信息,包括:

  • 类的信息: 类名,报名,访问限制符,父类,实现的接口,注解
  • 字段信息: 也叫域信息,是类中所有的成员变量
  • 方法信息: 方法的名字,参数,访问限制符,还包括方法本身需要执行的字节码
  • 类加载器的引用: 方法区的类信息中会记录加载该类的类加载器,同样类加载器也会记录自己加载了哪些类
  • class 引用: 这里是指堆内存的 class 对象引用
  • 常量池: 其实都是字符串和编译时就能确定的数据,比如 final int 的值,编译的时候就能确定时多少,因为 final 的 int 是没有机会变化的,不要和运行时常量池混了,这里的常量池其实是为了减少字节码文件体积,尽量复用可能会重复的字符串,之后解析时会把这些字符串即符号引用转换成对应的对象引用,比如父类啊,属性类型啊,这些都会再解析时把对应的类加载出来,这样字符串就变成了类引用了
  • JIT 即时编译器编译过后的代码缓存

或者这张图,classFile 就是编译之后的class文件,它的数据结构就是这样的,单词不难,大家一看就知道咋回事了,就像一个Bean数据对象一样,记录一个类里面的都有啥,难点不好理解的是 constant_pool,这个下面会仔细说一下的

这是代中文对照的图:

堆、栈、元空间的相互关系:

2. 从字节码入手

其实我们从反编译下字节码就知道怎么回事了,字节码文件会加载到方法区,也就是数据储存结构有些变化,但是东西还是字节码里面的东西

public class Max {

    public static int staticIntValue = 100;

    static {
        staticIntValue = 300;
    }

    public final int finalIntValue = 3;
    public int intValue = 1;

    public static void main(String[] args) {
        try {
            Thread.sleep(100000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public void speak() {
        int a = 10;
        int b = 20;
        int c = a + b;
    }
}
复制代码

反编译下:java -v -p Max.class > Max.txt,加 -p 是因为私有属性不加这个不先是,最后 > Max.txt 是把反编译出来的字节码写入到txt文件中,这样方便看

Classfile /Users/zbzbgo/Desktop/Max.class
  
  // 字节码参数
  Last modified 2020-6-20; size 746 bytes
  MD5 checksum 5c6bccb4965bf8e6408c8e3ef8bca862
  Compiled from "max.java"
  
// 包名+类名  
public class com.bloodcrown.bw.Max
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER // 访问限定符
 
// 常量池,里面其实都是字符串,需要解析时加载 
Constant pool:
   #1 = Methodref          #11.#30        // java/lang/Object."<init>":()V
   #2 = Fieldref           #10.#31        // com/bloodcrown/bw/Max.finalIntValue:I
   #3 = Fieldref           #10.#32        // com/bloodcrown/bw/Max.intValue:I
   #4 = Long               100000l
   #6 = Methodref          #33.#34        // java/lang/Thread.sleep:(J)V
   #7 = Class              #35            // java/lang/InterruptedException
   #8 = Methodref          #7.#36         // java/lang/InterruptedException.printStackTrace:()V
   #9 = Fieldref           #10.#37        // com/bloodcrown/bw/Max.staticIntValue:I
  #10 = Class              #38            // com/bloodcrown/bw/Max
  #11 = Class              #39            // java/lang/Object
  #12 = Utf8               staticIntValue
  #13 = Utf8               I
  #14 = Utf8               finalIntValue
  #15 = Utf8               ConstantValue
  #16 = Integer            3
  #17 = Utf8               intValue
  #18 = Utf8               <init>
  #19 = Utf8               ()V
  #20 = Utf8               Code
  #21 = Utf8               LineNumberTable
  #22 = Utf8               main
  #23 = Utf8               ([Ljava/lang/String;)V
  #24 = Utf8               StackMapTable
  #25 = Class              #35            // java/lang/InterruptedException
  #26 = Utf8               speak
  #27 = Utf8               <clinit>
  #28 = Utf8               SourceFile
  #29 = Utf8               max.java
  #30 = NameAndType        #18:#19        // "<init>":()V
  #31 = NameAndType        #14:#13        // finalIntValue:I
  #32 = NameAndType        #17:#13        // intValue:I
  #33 = Class              #40            // java/lang/Thread
  #34 = NameAndType        #41:#42        // sleep:(J)V
  #35 = Utf8               java/lang/InterruptedException
  #36 = NameAndType        #43:#19        // printStackTrace:()V
  #37 = NameAndType        #12:#13        // staticIntValue:I
  #38 = Utf8               com/bloodcrown/bw/Max
  #39 = Utf8               java/lang/Object
  #40 = Utf8               java/lang/Thread
  #41 = Utf8               sleep
  #42 = Utf8               (J)V
  #43 = Utf8               printStackTrace
{

  // 成员变量信息
  public static int staticIntValue;
    descriptor: I
    flags: ACC_PUBLIC, ACC_STATIC

  public final int finalIntValue;
    descriptor: I
    flags: ACC_PUBLIC, ACC_FINAL
    ConstantValue: int 3

  public int intValue;
    descriptor: I
    flags: ACC_PUBLIC

  // 默认的构造方法
  public com.bloodcrown.bw.Max();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: iconst_3
         6: putfield      #2                  // Field finalIntValue:I
         9: aload_0
        10: iconst_1
        11: putfield      #3                  // Field intValue:I
        14: return
      LineNumberTable:
        line 8: 0
        line 17: 4
        line 19: 9

  // 方法信息
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: ldc2_w        #4                  // long 100000l
         3: invokestatic  #6                  // Method java/lang/Thread.sleep:(J)V
         6: goto          14
         9: astore_1
        10: aload_1
        11: invokevirtual #8                  // Method java/lang/InterruptedException.printStackTrace:()V
        14: return
      Exception table:
         from    to  target type
             0     6     9   Class java/lang/InterruptedException
      LineNumberTable:
        line 23: 0
        line 26: 6
        line 24: 9
        line 25: 10
        line 27: 14
      StackMapTable: number_of_entries = 2
        frame_type = 73 /* same_locals_1_stack_item */
          stack = [ class java/lang/InterruptedException ]
        frame_type = 4 /* same */

  public void speak();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: bipush        10
         2: istore_1
         3: bipush        20
         5: istore_2
         6: iload_1
         7: iload_2
         8: iadd
         9: istore_3
        10: return
      LineNumberTable:
        line 30: 0
        line 31: 3
        line 32: 6
        line 33: 10

  // 类的初始化方法 clinit(C++) 方法,编译时自动生成的。注意不是默认的构造函数,静态代码块和静态属性赋值,写代码时谁写在前面,谁的赋值就在前面,注意有先后顺序
  static {};
    descriptor: ()V
    flags: ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: bipush        100
         2: putstatic     #9                  // Field staticIntValue:I
         5: sipush        300
         8: putstatic     #9                  // Field staticIntValue:I
        11: return
      LineNumberTable:
        line 10: 0
        line 13: 5
        line 14: 11
}
SourceFile: "max.java"
复制代码

大家看个意思,方法区储存的类信息其实和字节码差不了对少

3. 方法区的储存结构

图里的字符串常量池不方法区里,JVM 规范虽然是这样的,但是具体的虚拟机实现都会有变化的,具体看实际

  • 存储位置:
    方法区不在用户进程的内存中,而是在本地内存 native memory 中,这样的好处是加载过多的类也不会造成堆内存的OOM了
  • 方法区数据结构:
    操作系统中,可以有多个 JVM 实例,看着每个JVM都有自己的方法区。但实际上在 native memory 内存中,方法区只有一块。每个类加载器在方法区都可以申请一块自己的空间,类加载器相互之间不能访问,每个类加载器自己的空间内,给每一个类信息都分配一块空间,就像 Map<Classload,Map<String,Class>> 这样的数据结构一样。系统类加载器是 static 的,每个进程的系统类加载器是都是不同的对象,对应的方法区空间也不一样,所以他们之间加载的类信息是不能共享的,好比A进程加载Dog的1.3版本,B进程加载Dog的1.0版本,这并不影响进程A和B之间的独立运行
  • classload 和方法区class相互记录:
    方法区里的class类信息对象会记录自己是哪个类加载器加载的,类加载器一样会记录自己加载过哪些类信息

4. 方法区的 OOM

方法区默认大小是20.75M,android 上是20.79M,最大值是一个无限大的数,但是实际上是物理内存的上限,超过这个上限一样也会 OOM

  • jinfo -flag MetaspaceSize 74290 命令可以查看方法区大小
  • -XX:MetaspaceSize=100m 在 VM options 设置方法区大小,方法区的最大值一般不动,我们调节的都是方法区一上来的默认大小

方法区我们可以设置固定大小,也可以设定动态调整,默认是动态调整的,一旦方法区满了就会触发 Full GC,GC 会去回收方法区中不被用到的 class 类信息,什么时候 class 类信息不被用到呢。就是加载 class 类信息的 classload 销毁了,那么这个这个 classload 加载的所有的 class 类信息都无用了,可以被回收了

5. 理解什么是常量池

常量池这东西我们应该清楚的,即便网上的资料,看那些文字描述基本看不懂,但是这不是我们不去理解的理由,方法区和类加载机制是紧密联系的,所以方法区的一切我们都应该知道

常量池这块挺复杂的:

  • classfile 里面的叫常量池
  • 方法区里面的叫运行时常量池

他俩之间的关系:

  • 字节码文件 classfile 被 classload 加载到内存之后,字节码文件中的常量池就变成运行时常量池了

一定要搞清楚他俩是什么,我一开始看这里的时候头疼啊,一会常量池,一会运行时常量池,我都怀疑网上的文章是不是写错了,去看《深入理解java虚拟机》这本书又写的不连贯,写的莫名其妙,看着描述的文字很多,但就是没说明白这是啥

其实他俩的关系就是一句话:文件里的字节码常量池加载到内存之后就是运行时常量池了。学习他俩其实把字节码的常量池搞明白就行了,剩下那个自然就懂了

先看看常量池的字节码吧,用的是前面反编译出来的字节码

Constant pool:
   #1 = Methodref          #11.#30        // java/lang/Object."<init>":()V
   #2 = Fieldref           #10.#31        // com/bloodcrown/bw/Max.finalIntValue:I
   #3 = Fieldref           #10.#32        // com/bloodcrown/bw/Max.intValue:I
   #4 = Long               100000l
   #6 = Methodref          #33.#34        // java/lang/Thread.sleep:(J)V
   #7 = Class              #35            // java/lang/InterruptedException
   #8 = Methodref          #7.#36         // java/lang/InterruptedException.printStackTrace:()V
   #9 = Fieldref           #10.#37        // com/bloodcrown/bw/Max.staticIntValue:I
  #10 = Class              #38            // com/bloodcrown/bw/Max
  #11 = Class              #39            // java/lang/Object
  #12 = Utf8               staticIntValue
  #13 = Utf8               I
  #14 = Utf8               finalIntValue
  #15 = Utf8               ConstantValue
  #16 = Integer            3
  #17 = Utf8               intValue
  #18 = Utf8               <init>
  #19 = Utf8               ()V
  #20 = Utf8               Code
  #21 = Utf8               LineNumberTable
  #22 = Utf8               main
  #23 = Utf8               ([Ljava/lang/String;)V
  #24 = Utf8               StackMapTable
  #25 = Class              #35            // java/lang/InterruptedException
  #26 = Utf8               speak
  #27 = Utf8               <clinit>
  #28 = Utf8               SourceFile
  #29 = Utf8               max.java
  #30 = NameAndType        #18:#19        // "<init>":()V
  #31 = NameAndType        #14:#13        // finalIntValue:I
  #32 = NameAndType        #17:#13        // intValue:I
  #33 = Class              #40            // java/lang/Thread
  #34 = NameAndType        #41:#42        // sleep:(J)V
  #35 = Utf8               java/lang/InterruptedException
  #36 = NameAndType        #43:#19        // printStackTrace:()V
  #37 = NameAndType        #12:#13        // staticIntValue:I
  #38 = Utf8               com/bloodcrown/bw/Max
  #39 = Utf8               java/lang/Object
  #40 = Utf8               java/lang/Thread
  #41 = Utf8               sleep
  #42 = Utf8               (J)V
  #43 = Utf8               printStackTrace
复制代码

大家看看这常量池的字节码感觉像啥,像不像list列表,有int索引,数据一行一行很规则

没错,常量池本质就是一张表,虚拟机根据字节码指令来这张表找到和这个类关联的类、方法名、参数类型、字面量等信息

大家注意啊,常量池里面存的都是字符串,为啥?class文件不是字符串能是什么,就算加载到内存里对应的还是字符串,只有类加载器根据这么字符串信息把这个类加载出来,这些字符串才有意思

所以像:java/lang/Object、java/lang/Thread、com/bloodcrown/bw/Max.intValue这些,我们知道其实他们是什么,是类的类型,接口,方法,包名等等,常量池中的这些字符串就叫做符号引用

但是单单字符串我们是没法用的,必须要类加载器把这些字符串描述的类都正式在内存中加载出来才有意义。这个过程在类加载机制中的解析环节,会把常量池中这些字符串转换成加载过后的class类型信息在方法区中的地址,这个地址叫做:直接引用

从常量池->运行时常量池=从符号引用->直接引用,说白了就是把字节码中描述信息的字符串都正式加载成出来,生成对应的类、接口、注解等等可以使用的信息

总结一下,常量池的内容包括:

  • 数值量: 比如 int=10 中的 10
  • 字符串
  • 类引用
  • 字段引用
  • 方法引用

那为什么要涉及一个常量池出来呢,既然都是字符串,我们写在用的地方不就好了嘛~距网上的解释,JVM 官方是考虑到有的字符串会重复被使用,为了尽可能减少class文件体积。另一个考虑是,每个类里面其实都涉及其他类,如果不用字符串代替class本身涉及到的其他的类型信息,那么就要把这些涉及到的类型信息都写在同一个class文件里,那么这回造成灾难性的后果,class文件大到难以接收,文件结构也会变得不可预期,大量的class文件中都会有重复信息,甚至涉及到不同类型的版本,这样就没法搞了

6. JDK1.8 方法区变化

前文说过,方法区是 JVM 规范的称为,只是一种建议规范,并且还没有做强制限制。具体设计成什么样,还得看看方法区的具体实现,永久带和元空间就是方法区的具体实现,区别很大

永久带这东西只有 hotspot 虚拟机再 JDK1.6 之前才有,其他虚拟机像 JRockit、J9 人家压根就不用,而是用自己的实现:元空间

永久代:设计在JVM内存中,和堆内存连续的一块内存中,储存类元信息、字符串常理池、静态数据,因为有JVM虚拟机单个实例的内存限制,永久带会较多几率触发 FullGC,并且垃圾回收的效率、性能还低,类加载的多还会出现 OOM,尤其是后台程序加载的模块多了

元空间:设计在本地内存 native memory,没有了JVM虚拟机内存限制,OOM 基本就杜绝了,FullGC 触发的几率较低。类元信息随着方法区中的迁移,改在本地内存中保存,字符串常量池和静态数据则保存在堆内存中

JDK 1.6 之前方法区采用永久带,JDK1.8 开始,方法区换用元空间,JDK1.7 在其中起过度

7. 方法区的GC

方法区不是没有GC的,只是规范没强制有,具体看方法区实现的心情了,当然元空间肯定是有的

大家需要知道方法区不足会引起 GC,而这个 GC 是 FullGC,性能消耗很大。方法区GC回收的其实就是运行时常量池里的东西

类元信息的回收条件非常苛刻,必须同时满足下面所有条件:

  • 该类 类型的所有实例都被回收了
  • 加载该类的类加载器已经被回收了
  • 该类对应的在堆内存中的class映射对象,没有被任何地方引用

蛋疼不,第三条有点说到的地方,我们反射时可是大量会用到class的,所以反射可能会造成类元信息的内存泄露

正是因为方法区回收的条件众多且必须一一满足又和堆内存息息相关,所以才会触发最重量家的 FullGC,把堆内存整体过一遍。回收的内容又没有堆内存那样多,可能有的人觉得这点内存其实没必要回收,但是以前Sun公司因为方法区没有GC回收问题而引起过不少重量级bug,所以方法区的回收是一件必须的事情,但是又是一件费力不讨好,还性能消耗大的事,所以在后端开发时,方法区初始值一般都尽量设置的大一些,为了就是减少方法区GC

大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP,及其 OSGI 这类频繁自定义类加载器的场景中,通常都是需要 JVM 具备类型卸载的能力,以保证不会对方法区造成多大的压力

方法区引起的 Full GC,STW 会稍长,因为要查找的引用比较多

8. 补充

这是从别人那里看过来的,想了想应该是正确的

一个称之为类数据共享(CDS)的特性自HotspotJVM 5.0开始被引进。在安装JVM期间,安装器加载一系列的Java核心类(如rt.jar)到一个经过映射过的内存区进行共享存档。CDS减少了加载这些类的时间从而提升了JVM的启动速度,同时允许这些类在不同的JVM实例之间共享。这大大减少了内存碎片

对象在堆内存中的储存结构

1. 储存结构

说完类加载器和方法区我就可以来看对象是怎么在堆内存存储的了

就是这个样子:

对象在堆内存中的存储结构:

  • 对象头
    • Mark Word:也称运行时元数据,一个64位的数字,使用位运算分段保存对象的一些信息,包括:哈希值(对象内存的首地址),GC分代年龄,锁状态,偏向线程ID,偏向时间搓
    • Kclass Word 类型指针:指向该类在方法区对应的类元数据地址(KClass对象)
    • 数组长度:如果这是数组对象的话,惠济路数组的长度
  • 对象实体
    • 注意会先储存父类的属性,内存占用相同的属性会放在一起
  • 对齐方式
    • 64位系统JVM默认对8字节对齐,简单说就是必须被8整除,不能被8整除,这里会添加一些大小以实现被8整除

其中详细的指针看下图:

2. 空对象占内存多少

这是一道常问的面试题,我们只考虑一般对象,对象实体是空的,Mark Word 占64位8个字节

类型指针默认是8个字节的,但是在开启指针压缩时会变成4个字节,JDK8 是默认开启的

参考对齐方式,所以一个空对象默认占用内存大小是12个字节,算上对齐方法的化是16个字节

3. java 中的 oop-klass 模型

学习JVM的话,oop-klass 模型永远是一个绕不过去话题。我们都知道 HotSpot VM 几乎可以说是纯 C++ 语言编写的 Java 虚拟机,那么 Java 的对象模型和 C++ 的对象模型之间究竟有什么关系呢?这个问题简单回答就是 oop-kclass 二分对象模型

具体来说:HotSpot 虚拟机采用 Klass-OOP 模型来存储 java 对象,OOP(Ordinary Object Pointer)指的是普通对象指针,是 java 部分的。而 Klass 用来描述对象实例的具体类型,是 C++ 的。oop 在堆内存中,就是对象头,Kclass 是方法区类元数据的数据模型

至于为啥要搞这一个东西呢,我从网上找来的,这个应该是解释的最正确的了吧

事实上HotSpot底层究竟怎么表示一个Java对象这个问题归根结底就是C++怎么表述一个Java对象。有一个朴素的实现方案就是将每一个Java对象都影射为一个对等的C++对象,然而这么做确实是太朴素了,它有一个严重的弊端就是如果这样做的话那么就不得不为每一个Java对象都保存一份VTable(虚函数表),因为C++的多态实现就是为每一个对象都保留一份VTable。这是很浪费空间的,所以HotSpot设计者采用了oop-class二分模型来表述一个Java对象。其中这里的oop表示Ordianry Object Pointer(普通对象指针,注意可不是object-oriented programming),它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象。而 klass 则包含 元数据和方法信息,用来描述 Java 类

那么为何要设计这样一个一分为二的对象模型呢?这是因为HotSopt JVM的设计者不想让每个对象中都含有一个vtable(虚函数表),所以就把对象模型拆成klass和oop,其中oop中不含有任何虚函数,而klass就含有虚函数表,可以进行method dispatch。这个模型其实是参照的 Strongtalk VM 底层的对象模型

总结来说:就是对象数据在 java 和 C++ 2种语言直接联系(个人理解)

还记的对象的对象头码,Mark Word + Kclass Word,oop-klass 模型的 oop 就是这个对象头。对象的创建这一步中就专门有生成分配设置对象头这一步

oop 的具体类型有:instanceOopDesc(实例对象)/arrayOopDesc(数组对象),oop 是 方法区 C++ 类元信息在 java 数据,模型中的映射

instanceOopDesc 继承自 oopDesc,看看 oopDesc 的源码:

class oopDesc {
  friend class VMStructs;
 private:
  volatile markOop  _mark;
  union _metadata {
    Klass*      _klass;
    narrowKlass _compressed_klass;
  } _metadata;

  // Fast access to barrier set.  Must be initialized.
  static BarrierSet* _bs;
...
}
复制代码

_mark 是对象头里的 Mark World,_klass 和 _compressed_klass 都是方法区 Kclass 对象的引用地址,_compressed_klass 是开启指针压缩之后的内存地址

instanceKlass 是类元信息的具体数据模型,具体不解释了

class InstanceKlass: public Klass {
  ...
  enum ClassState {
    allocated,                          // allocated (but not yet linked)
    loaded,                             // loaded and inserted in class hierarchy (but not linked yet)
    linked,                             // successfully linked/verified (but not initialized yet)
    being_initialized,                  // currently running class initializer
    fully_initialized,                  // initialized (successfull final state)
    initialization_error                // error happened during initialization
  };
  ...
 } 
复制代码

Java 虚拟机是如何通过栈帧中的对象引用找到对应的对象实例的,看图:

一般介绍 oop-Kclass 的到这里就结束了,但是这里还有一个重要角色,也是很多人搞不明白的,就是 CLASS 对象,我们知道每个类在堆内存中都有一个对应的class对象,尤其是反射的时候

// 这个class就是我们要说的东西
Class<Dog> dogClass = Dog.class;
复制代码

具体来说还是因为 C++ 和 java 语言的差异,Kclass 对象在方法区内,而方法区又不在堆内存而是在 Native Memory 本地内存中,Native Memory 不允许我们直接访问,所以为了便于衔接JVM内存结构,所以搞了一个句柄出来,对 JVM堆内存中的 class 对象就是 Kclass 对象的句柄,class 并可以访问 Kclass ,加之反射我们需要帮助JVM解析类的结构,所以就有了堆内存里的class对象

class对象是什么时候生成的呢,是和 Kclass 对象一起生成的,类加载机制在加载的时候在方法区生成一个 Kclass 对象,那么就会在类加载器所属的 JVM 堆内存中同步生成一个 class 对象

Class 本身也是一个 java 类型,其中基本都是 Native 方法

Class{
    public native Field getDeclaredField(String name) throws NoSuchFieldException;

    private native Field[] getPublicDeclaredFields();
}
复制代码

值得注意的是,class 没有直接指向 Kclass,而是 Kclass 内部指向了 class,我们需找 class 的路线是:oop(对象头)->kclass(方法区)->class(堆内存)

栈内存

栈内存简介

网上有句话:栈是运行时结构,堆是储存结构。栈是管程序如何执行,怎么处理数据的。这句话基本道尽了 java 栈内存的作用,栈就是管运行的

栈内存同程序计数器,本地方法栈一起是线程私有的,有一个线程 new 出来,就会开辟一块栈内存出来

栈内存采用数据结构,其内部保存的是一个一个栈帧,一个栈帧对应一个java方法。栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器,栈内存有OOM,但是没有GC的需求,空间不够了直接OOM

栈内存的作用就是主管程序的运行,保存方法的局部变量,部分结果,并参与方法的调用和返回,生命周期和线程一致

栈内存调试

JVM 允许栈内存的大小是固定的或者是动态调整的,这个看你具体设置的JVM 参数了

JDK8 时 栈默认大小1M,最小160K,小于 160K直接报错

还有一个参数,栈深度,就是方法调用链的深度,用递归来举例 10086,就是递归10086此调用自己这个方法了

栈内存不够用了也是会 OOM 的:

  • 栈内存是的固定话,会抛 stackOverFlowError
  • 栈内存是动态的话,会抛 outOfMemoryError

JVM 参数:

  • Xss: 栈内存值
  • 实际这么写:-Xss256k

记住栈内存只有这么一个 JVM 参数,当最大值用就行啦

默认情况下我们搞一个 OOM 看看:

public class Max {

    public static int index = 0;

    public static void main(String[] args) {
        index++;
        System.out.println(index);
        main(args);
    }

}
复制代码

可以看到抛出的哪个异常,这对于JVM调优是非常重要的,另外可以看到栈深度大概是1000左右,默认1M的栈大小,这么一算的话一个栈帧大概要占200个字节左右,所以写递归时一定要注意,稍不注意栈内存就溢出了,别堆内存没事,栈到先顶不住了...

打印栈内存大小: XX:+PrintFlagsFinal -version | grep ThreadStack

➜  ~ java -XX:+PrintFlagsFinal -version | grep ThreadStack
     intx CompilerThreadStackSize                   = 0                                   {pd product}
     intx ThreadStackSize                           = 1024                                {pd product}
     intx VMThreadStackSize                         = 1024                                {pd product}
复制代码

特别需要注意的是,GC 垃圾回收机制不会涉及到栈内存,栈内存相对于堆内存是不可见的,我们常说的 GC 只会对堆内存起作用,甚至方法区都有自己专门的垃圾回收器

什么是栈帧

栈内存是 stack 栈这种先入后出的数据结构,每一个栈帧代表的是一个方法,方法执行时所需要,所产生的数据都包含在栈帧中,栈帧就可以看成方法执行在内存中的样子

其实这几句话我都不想写的,能看我这篇文章的,栈和堆基本都知道

栈帧结构

一个栈帧代表一个方法,方法在运行过程中会有传入的参数,自己创建的属性,执行结果等等的东西,所以栈帧的结构比较复杂,因为要分门别类储存这些东西:

  • 局部变量表
  • 操作数栈
  • 动态链接 运行时常量池中方法的引用
  • 返回地址
  • 附加信息

先说 动态链接 这个东西,方法的字节码存在哪?当然是方法区里面啦,一个类的方法肯定会有很多地方都用到啊,不能每一个地方都保存一份方法运行的字节码啊,那内存可就搂不住啦,所以栈帧里面必须有一个属性要能在方法运行之时把方法的字节码拿到交给执行引擎去执行,动态链接干的就是指向方法区Kclass对象运行时常量池中该方法的引用

动态链接这里有个点,静态链接和动态链接,涉及黑科技的东西大家要知道:

  • 静态链接: 当一个字节码被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,在这种情况下将调用方法的符号引用转换位直接引用,这个成为静态链接。就是一旦编译就不变了
  • 动态链接: 如果被调用的目标方法在编译期无法确定下来,只能在程序运行时将调用方法的符号引用转换位直接引用,由于这种引用转换过程具备动态性,因此被成为动态链接。比如一个方法接受一个接口对象类型的参数,对于方法来说,编译时怎么知道会具体传什么类型的实现类进来,这个就是动态绑定

返回地址,附加信息这2个不必说,相比大家都能猜的出,重点是局部变量表和操作数栈

局部变量表 其实也好理解,她就是一个数组,保存方法接收到的参数和在方法运行时生成的对象的引用,对象本身还是保存在堆内存里面的,在方法运行的时刻会拷贝赋值一份到线程所属的工作内存中,栈帧所属的栈内存也在工作内存中。局部变量表的基本单位是 slot,一个slot占4个字节,8个字节的基本数据类型占2个slot,引用类型的指针占2个slot。局部变量表是栈帧中占据内存空间最大的部分,一个对象引用就要占8个字节出去

操作数栈 在方法运行过程中,根据字节码指令,往栈中写入活提取数据,push 入栈、pop 出栈,保存方法运行过程中产生的中间数据和临时数据,是完全为了方法执行服务的,其保存的值没有最终的实际意义,是位字节码指令执行服务的。比如让 Dog a、Dog b 交换对象实例的操作,中间产生的tem这个临时变量的引用,就会存储在操作数栈里

操作数栈的字节码分析按理说淂写一遍的,要不就是不到位,但是这里我就真的不想写了,B站上讲JVM的都会把这块说的滚瓜烂熟,留给大家当个思考题吧,不能什么都靠别人不是,自己动手丰衣足食...

栈在内存的哪个位置

这个问题绝对会难倒绝大部分人的,因为我就是 ︿( ̄︶ ̄)︿

栈内存中值得我们深入思考的是栈和工作内存的关系以及执行情况,JMM 不了解的请看:JMM和底层实现原理

JMM 是什么:MM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JMM规定每个线程都有自己的工作内存,工作内存是什么:工作内存是寄存器和高速缓存的抽象。说栈就是工作内存绝对是错误的,栈虽然默认是1M的,很小,但每个进程允许创建的现场数最多可以达5000个,栈要是不设置小一点,内存装的下嘛~,栈是运行在线程所属工作内存的,但是会随着硬件吃紧缓存到内存中

栈代表的是方法的执行,需要快速高效,而内存相对于CPU内部的寄存器和缓存来说要慢上百倍,这是不能接收的,所以每个线程才会在cpu缓存上有自己的一块空间也就是工作内存。但是cpu缓存很小,桌面CPU AMD R3600X L3才32M,一个线程就要占1M走,那线程多了cpu也搂不住啊。移动cpu 高通家的晓龙865 3级缓存在4M,明显不够用

所以线程在获取cpu时间片时,其工作内存一定会在cpu L3中运行,一旦失去cpu时间片还是会保存在cpu缓存中的,但是一旦cpu缓存不够用了,cpu缓存会执行清理工作,这时会把失去cpu时间片的线程工作内存移至内存中缓存,用时再加载会cpu缓存中,所以cpu缓存越大多线程性能越好,线程切换更少

还有另外一个佐证:栈帧中的局部变量表也是垃圾回收机制重要的根节点。可见垃圾收集器是能访问到栈内存的,栈内存要是常驻cpu告诉缓存中的话,垃圾回收器是访问不到的

这里我迷糊了好久,搞清这个问题我是花了很多功夫的...

写到这里我终于找到了明确的答案:

由于操作数是存储在内存中的,因此会频繁的执行内存读/写操作,必然会影响执行速度。纬二路解决这个问题,Hotspot 虚拟机的设计者们提出了栈顶缓存技术,讲栈顶元素也就是马上要执行的栈帧(方法)缓存在cpu寄存器中,以此降低对内存的读写次数,提升执行引擎的效率

结合这段话,我是我们可以猜测下,栈顶的栈帧放入cpu缓存的优先级肯定是:L1->L2->L3 的吗,我估计即便线程失去cpu时间片,可能还会缓存该线程下一个栈帧到L3中,估计会结合java的锁升级机制,通过锁可以知道哪个线程未来执行机会大

本地方法栈

还是有必要说一下,找到一段经典解析:

当某个线程调用一个本地方法时,她就进入了一个全新的并且不再受虚拟机限制的世界,他和虚拟机拥有同样的权限

  • 本地方法通过本地方法接口来访问虚拟机内存的运行时数据区
  • 可以直接使用cpu中的寄存器
  • 可以直接从本地内存的堆中分配任意数量的内存

并不是所有的JVM都支持本地方法栈,java 虚拟机规范并没有明确规定本地方法栈使用的语言,具体实现等。如果JVM产品不打算直接native方法,可以没有本地方法站的

hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一了

栈内存使执行 java 方法的,本地方法栈是执行 C/C++ 方法的,知道这么多就行啦 o( ̄ヘ ̄o#) 等你研究 C++ 时可以再深入理解