expand-01-深入Java虚拟机
✨你好啊,我是“ 罗师傅”,是一名程序猿哦。
🌍主页链接:楚门的世界 - 一个热爱学习和运动的程序猿
☀️博文主更方向为:分享自己的快乐 briup-jp3-ing
❤️一个“不想让我曾没有做好的也成为你的遗憾”的博主。
💪很高兴与你相遇,一起加油!
前言
目标:JVM、JDK源码、高并发、MySql优化
虚拟机概述
发展历程
Java 往事
版本迭代
两种JDK
JVM体系
- JDK(Java Development Kit)是Java语言的软件开发工具包,也是整个Java开发的核心,它包含了JRE和开发工具包
- JRE(Java Runtime Environment), Java运行环境,包含了JVM和Java的核心类库(Java API)
- JVM(Java virtual Machine),Java虚拟机,它是运行在操作系统之上的,它与硬件没有直接的交互
总结:JDK是开发人民的工具包,它包含了Java的运行环境和虚拟机,而一次编到处运行就是基于JVM
各种虚拟机
清单
- Sun Classic VM
查看
JVM整体架构
Java运行过程
- 源码编译:通过Java源码编译器将Java代码编译成JVM字节码(.class文件)
- 类加载:通过ClassLoader及其子类来完成JVM的类加载(.class文件—>加载—>方法区中)
- 类执行:字节码被装入内存,进入JVM虚拟机,被解释器解释执行
JVM模型
- 类加载子系统
Java虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型
- 运行时数据区
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。
这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
- 执行引擎
执行引擎用于执行JVM字节码指令,主要有两种方式,解释执行和编译执行。
区别在于,解释执行是在执行时翻译成虚拟机指令执行,而编译执行是在执行之前先进行编译再执行。
解释执行启动快,执行效率低;编译执行,启动慢,执行效率高。
垃圾回收器自动管理运行数据区的内存,将无用的内存占用进行清除,释放内存资源。
- 本地方法库、本地库接口
在JDK的底层,有一些实现需要调用本地方法完成(使用C或C++写的方法),就是通过本地库接口调用 的,比如System.currentTimeMillis()方法。
1 | public static native long currentTimeMillis(); |
类文件结构
测试案例
源代码
1 | package com.briup.classfile; |
编译
1 | javac ClassStruct.java |
字节码结构
二进制概览
1)插件使用
本人使用的是notepad++ 插件 Hex Editor,当然你也可以使用vsCode 中的Hex Editor这个插件
- vsCode
- notepad++
查看字节码
2)class文件时一个二进制文件,转换后以16进制展示,实际上class文件就是一张表,它由以下数据项构成,这些数据项从头到尾严格按照以下顺序排列:
class文件只有两种数据类型:无符号数和表
魔数与版本
- 魔数
开头的4个字节表示的时魔数, CAFEBABA=咖啡宝宝
魔数就是要来区分文件类型的一种标志,一般都是用文件的前几个字节来表示
- 版本号
紧跟着魔数后面的4位是版本号,同样也是4个字节,其中前2个字节表示 副版本号 ,后2个字节表示 主版本号 。
在开发中,有时会遇到类似Unsupported major.minor version 51.0的错误,一般情况下都是JDK版本不匹 配造成的。 虽然JDK在执行代码时基本上向下兼容,但开发环境和服务器环境JDK最好一致,不要尝试这个坑。
区分和理解两个环境:编译环境、运行环境
常量池
再往下遵从相同的规律: 计数器(标注后面有多少个) + 对应个数的结构体
下面以常量池为例子:
常量池记录了JVM内的一堆常量信息,这部分由 【2个字节计数】 + 【n个cp_info结构】组成
常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。
字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。
符号引用属于编译原理方面的概念,包括下面三类常量:
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
- 方法的名称和描述符
其中cp_info由多种类型:
- 直接类型,存的就是当前值,这种像Integer、Long等长度都是确定的
- 引用类型,存的是指向其他位置的指针
3)案例
下面以String为例,String是一种引用类,它会指向一个utf8类型来存储真实的信息
1 | # JDK提供了一个工具javap,可以查看常量列表的详细内容: |
其他信息
运行数据区
字节码只是一个二进制文件存放在那里,要想在JVM里跑起来,先得有个运行的内存环境,也就是我们 所说的JVM运行时数据区。
- 运行时数据区的位置
运行时数据区是JVM中最为重要的部分,执行引擎频繁操作的就是它。类的初始化,以及对象空间的分配、垃圾的回收都是在这块区域发生的。
- 区域划分
程序计数器
概述
溢出异常
没有!在虚拟机规范中,没有对这块区域设定内存溢出规范,也是唯一一个不会溢出的区域。
案例
虚拟机栈
概述
- 是线程私有的,生命周期与线程相同
- 它描述的是Java方法执行的当前线程的内存模型,每个方法被执行的时候,Java虚拟机都会创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每个方法从被调用直至执行完毕的过程,就对应着 一个栈帧在虚拟机栈中从入栈到出栈的过程
溢出异常
案例一:进出栈顺序
1 | /** |
案例二:栈深度溢出
1 | /** |
案例三:栈内存溢出
试试就试试~
1 | /* |
本地方法栈
概述
- 本地方法栈的功能和特点类似于虚拟机栈,均具有线程隔离的特点
- 不同的是,本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的Java方法
- 虚拟机规范里对这块所用的语言、数据结构、没有强制规定,虚拟机可以自由实现它
- 甚至,HotSpot把它和虚拟机合并成了1个
溢出异常
和虚拟机栈一样,也是两个:
- 如果创建的栈的深度大于虚拟机允许的深度,抛出 StackOverFlowError。
- 内存申请不够的时候,抛出 OutOfMemoryError。
堆
Java堆是垃圾收集器管理的内存区域,因此它也被称作“GC堆”,这就是我们做JVM调优的重点区域。
JDK1.7
JDK1.8
需要特别说明的是:Metaspace所占用的内存空间不是在虚拟机内部,而是在本地内存空间中,这也是与1.7的永久代最大的区别
溢出异常
内存不足时,抛出 java.lang.OutOfMemoryError: Java heap space
案例:堆溢出
- 代码
1 | /** |
- 启动
注意启动时,指定一下堆的大小:-Xms20m -Xmx20m
方法区
溢出异常
- 1.6:OutOfMemoryError: PermGen space
- 1.8:OutOfMemoryError: Metaspace
案例一:1.6方法区溢出
在1.6里,字符串常量是运行时常量池的一部分,归属于方法区,放在了永久代里。
1 | /* |
1 | /** |
案例二:1.8方法区溢出
1 | // 所以不论配置下面那个参数都不生效,因为字符串常量池根本不在元空间中。 |
- 那如何才能让元空间溢出呢?
既然字符串常量池不在这里,那就换其他的,类的基本信息总在元空间吧?
我们来试一下 cglib是一个强大的、高性能、高质量的Code生成类库,它可以在运行期扩展Java类与实现Java接口,可以在运行时生成大量的对象。
1 | /** |
一个案例
假设有个Bootstrap的类,执行main方法,在JVM里,它从class文件到跑起来,大致经过如下步骤:
- 首先JVM会将这个Bootstrap.class信息加载到内存中的方法区
- 接着,主线程开辟一块内存空间,准备好程序计数器pc、虚拟机栈、本地方法栈
- 然后,JVM会在Heap堆上位Bootstrap创建一个
Class<Bootstrap>
的类实例 - JVM开始执行main方法,这时在虚拟机栈里为main方法创建一个栈帧
- main方法在执行的过程之中,调用了greeting方法,则JVM会为greeting方法再创建一个栈帧,推到虚拟机栈顶,再main的上面,每次只有一个栈帧处于活动状态,当前为greeting
- 当greeting方法运行完成后,则greeting方法出栈,当前活动帧指向main,方法继续往下运行
归纳总结
- 独享/共享的角度:
- 独享:程序计数器、虚拟机栈、本地方法栈
- 共享:堆、方法区
- error的角度:
- 程序计数器:不会溢出,比较特殊,其他都会
- 两个栈:可能会发生两种溢出
- 深度超了报:StackOverflowError
- 空间不足报:OutOfMemoryError
- 堆:只会再空间不足时,报:OutOfMemoryError,会提示heapSpace
- 方法区:空间不足时,报:OutOfMemoryError
- 1.6 是 permspace
- 1.8 是 meterspace
- 归属
- 计数器、虚拟机栈、本地方法中:线程创建必须配套申请,真正的物理空间
- 堆:真正的物理空间,但是内部结构的划分有变动
- 方法区:最没归属感的一块,原因就是它是一个逻辑概念。1.6被放在了堆的永久代,1.8被拆分, 一部分在元空间,一部分被放在了堆里(方法区的运行时常量池里面的类对象,包括字符串常量)
- 直接内存:这块实际上不属于运行时数据区的一部分,而是直接操作物理内存。在nio操作里 DirectByteBuffer类可以对native操作,避免流在堆内外的拷贝。
类加载
加载
注意:
- 加载的字节码来源,不一定非得是class文件,可以是符合字节码规范的任意地方,甚至二进制流等。
- 从字节码到内存,是由类加载器(ClassLoader)完成的
系统类加载器
- Bootstrap
- ExtClassLoader
- AppClassLoader
- Code
1 | /** |
自定义类加载器
除了上面系统提供的3种类加载器,JVM允许自己定义类加载器,典型的在Tomcat上:
双亲委派(重点)
类加载器会优先调父类的load方法,如果父类能加载,直接用父类的,否则最 后一步才是自己尝试加载
1 | protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { |
验证
加载完成后,class里定义的类结构就进入了内存的方法区。 接下来,验证是连接阶段的第一步。
实际上,验证和上面的加载是交互进行的(比如class文件格式验证)。
之所以把验证放在加载的后面,是因为除了基本的class文件格式,还需要其他很多验证,我们逐个来看:
文件格式验证
元数据验证
字节码验证
符号引用验证
准备
变量类型
注意是类变量,也就是类里的静态变量,而不是new的那些实例变量,new的在下面的初始化阶段
- 类变量 = 静态变量
- 实例变量 = 实例化new出来的那些
存储位置
理论上这些值都在方法区里,但是注意,方法区本身就是一个逻辑概念。
- 1.6里,在永久代
- 1.8以后,静态类变量如果是一个对象,其实它在堆里
初始化值
注意:即使是static变量,它在这个阶段初始化进内存的依然是它的初始值,而不是你想要什么就是什么
1 | //普通类变量:在准备阶段为它开了内存空间,但是它的value是int的初始值,也就是 0! |
解析
初始化
概述
两个初始化
- 类变量与实例变量的区分
这里所说的初始化是一个class类加载到内存的过程,所谓的初始化值是类里定义的类变量,也就是静态变量
这个初始化要和new一个类区分开来,new的是实例变量,是在执行阶段才创建的
- 实例变量创建的过程(new)
- 在方法区中找到对应类型的类信息
- 在当前方法栈帧的本地变量表中放置一个reference指针
- 在堆中开辟一块空间,放这个对象的实例
- 将指针指向堆里对象的地址
对象创建
对象创建
概述
从你new一个对象开始,发生了什么?
遇到new指令,JVM首先要做的事是检查有没有这个类,有的话,加载它!
内存分配
类加载检查通过后,就要给新对象分配内存。
因为一个类型确定后,它内部定义了哪些结构哪些值,所需要的内存空间也就确定了。
- 指针碰撞(Bump The Pointer)
这种分配前提是内存中有整片连续的空间,用的在一边,空闲的在另一边,一个指针指向分界线。
需要多少指针往2空闲那边移动多少,直接划分出来一段,给当前对象。
- 空闲列表(Free List)
- 并发性
无论指针移动还是空闲列表的同一个指针空间,在并发分配的情况下会不会有问题?
确实有并发问题,那JVM是如何解决的呢?
方式一:CAS原子操作+失败重试
方式二:本地线程分配缓冲(TLAB)
对象创建时内存分配的流程:
- 内存区域
- 栈上分配使用的是栈来进行对象内存的分配
- TLAB分配使用的是Eden区域进行内存分配,实际上还是属于堆内存
- 优先级
- 栈上分配优先于TLAB分配进行,逃逸分析中若可进行栈上分配优化,会优先进行对象栈上直接分配内存
- 当无法进行栈上直接分配时,则会进行TLAB分配
内存布局
对象在堆上的布局,可以分为三个部分:对象头、实例数据、对齐填充。
对象头
一般分为两部分,Mark Word 和 类型指针(HotSpot)
- Mark Work, 官方叫法,其实就是存储对象自己运行时的数据
如哈希码、GC分代年龄、锁状态标记、线程持有的锁、偏向的线程id (具体的分类在下面)
- 类型指针
指向当前对象的类型,也就是方法区里,类信息的地址。(当然这里不是绝对的,HotSpot这么设计的。)
实例数据
对齐填充
对象的访问
我们的程序运行时,每一个方法相关的变量信息都在栈里,那么怎么找到这个对象呢?(栈中变量引用如何指向堆中对象)
句柄访问
栈指针指向堆里的一个句柄的地址,这个句柄再定义两指针分别指向类型和实例
很显然,拦截回收移动对象的话只需要改句柄即可,不会波及到栈,但是多了一次寻址操作。
直接地址
很显然,垃圾回收移动对象要改栈里的地址值,但是它减少了一次寻址操作。
备注:HotSpot使用的是直接地址方式
对象的销毁
JVM参数
分类
标准参数
-X参数
-XX参数
参数查询
垃圾回收概述
背景
实际上,垃圾回收并不是Java首创的,垃圾收集的历史远比Java语言本身还要久。
最早使用垃圾回收功能的语言是Lisp,于1960年诞生于麻省理工学院。