JVM课程笔记
【01】 概念介绍
网址:https://www.bilibili.com/video/BV1PJ411n7xZ
java和C++不一样的地方
- 内存动态分配
- 垃圾收集技术
JVM是一种跨语言的平台
像scala、kotlin、Jython、groovy 等都可以通过编译器编译成字节码文件,在JVM里面运行
JVM只关心字节码文件,而不关心文件是怎么被编译出来的
所谓虚拟机,就是一台虚拟的计算机,虚拟机分为系统虚拟机和程序虚拟机
- 系统虚拟机:VMWARE、安卓模拟器
- 程序虚拟机:JVM
JVM作用:给二进制字节码提供运行环境,特点:
- 一次编译 到处运行
- 自动垃圾回收
- 自动内存分配
JVM运行在操作系统上,与硬件系统没有直接交互
JVM整体结构
在java中多线程共享堆 和方法区,对于栈则是每个线程独占一个
编译器有两端:前端和后端,前端编译器是把JAVA语言生成字节码文件的,后端的编译器存在于执行引擎里,把字节码文件编译成可执行的形式
– 0725→ (p13)–
JVM是解释器和即时编辑器并存的架构
目前HotSpot VM是市面上高性能虚拟机代表作
java栈 又叫虚拟机栈
JVM架构模型
- 基于栈的指令架构(指令多,指令集小,性能没有寄存器好,具有跨平台性,在资源受限场景中适用型号)
- 基于寄存器的架构(指令更少,指令集大,由于要跨平台, CPU架构不同,JVM没有采用这个结构)
反编译指令: 在IDEA 的terminal中可以用javap -v ***.class反编译字节码文件
JVM生命周期
JVM的启动
JVM虚拟机的启动是通过引导类加载器(Bootstrap class loader)创建一个初始类来完成的
JVM的运行
执行Java程序
JVM的退出
程序正常执行结束、异常、Error等等(JNI规范了所有退出的方式)
JVM 发展历程
- SUN classic VM(解释器和JIT分离,每次都得编译所有代码,等待时间有点长 本来可以解释一点 执行一点)
- Exact VM
- Hotspot VM(重点,SUN公司开发),广泛用于JDK1.8
- 是Open JDK 默认虚拟机,所以非常广泛
- 有方法区,是其他的虚拟机木有
- HotSpot 名称就是 热点代码探测技术
- 通过编译器找到最具备编译价值的代码(热点代码),触发即时编译或栈上替换
- 编译器和解释器协同工作
- 三大商用虚拟机:HotSpot 、JRockit、IBM J9
- 其他:和硬件绑定的虚拟机:AZul JVM和BEA Liquid JVM
- Graal VM 多语言虚拟机,支持C C++ java python等 有望在未来替代hotspot
【02】 类加载子系统
类加载的过程分成三个步骤:加载→链接→初始化
- Loading
- Linking(验证→准备→解析)
- Initialzation
堆:堆中存放new出来的对象,是多个线程共享的
方法区:存放类和方法的信息
如果想要自己手写一个虚拟机的话,需要考虑哪些结构呢?
- 类加载子系统(让输入逐条放到内存中)
- 执行引擎(执行语句)
加载
- 通过一个类的全限定明,获取定义此类的 二进制字节流
- 将这个字节流所代表的的静态存储结构转化为方法区运行时的数据结构
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口(生成类实例是在此环节出现)
编译好的字节码文件,开头具有特殊标识:CAFEBABE 是个magic num
- 所有的变量,最开始prepare的时候都赋默认值,到initial赋具体的值。
- 常量(final static):会在这里初始化
下面这段代码num、number输出什么?
答案:num输出2,number输出10
原因:在类加载的时候,prepare阶段,就给num和number赋值0 然后在initialization阶段 分别先给num赋值1、2,给number赋值20、10(从上到下执行)
但是特别注意一点:在number=20下面不能System.out.println(number)会提示错误的前向引用,这是因为prepare阶段 这个变量还是一个半成品,所以编译器报错不能用这个变量。
这个代码输出啥?
答:执行子类的CLinit之前会执行完毕父类的,所以B=2。
类加载器分类
JVM支持两种类加载器:引导类加载器,自定义类加载器
所有派生于抽象类ClassLoasder的类加载器都划分成了自定义类加载器(和语义不一样)
引导类加载器(启动类加载器,Bootstrap ClassLoader)
- 这个类加载使用C/C++实现
- 用来加载Java核心库
- 并不继承自java.lang.classLoader,没有父加载器
- 加载扩展类和应用程序类加载器,并且当他们的爸爸(指定为他们的父类加载器)
- 出于安全,只加载包名为java、javax、sun开头的类
扩展类加载器
- Java语言编写,由sum.mis.Launcher$ExtClassLoader实现
- 派生于ClassLoader
- 父类加载器为启动器加载类
- 从java.ext.dirs系统属性所制定的目录中加载类库
应用程序类加载器(系统类加载器,AppClassLoader)
- Java语言编写,由sum.mis.Launcher$ExtClassLoader实现
- 派生于ClassLoader
- 父类加载器为扩展类加载类
- 负责加载环境变量classpath或系统属性java.class.path制定路径下的类库
- 是程序中的默认类加载器,
- 用户类使用系统类加载器进行加载
- 系统类使用引导类加载器进行加载(引导类加载器只加载核心类库)
- 有点像平民和贵族2333
- 引导类加载器是用C++写的,所以获取他的getClassLoader会提示null
引导类加载器和扩展类加载器加载:
用户自定义类加载器
问:什么时候需要自定义类加载器?
- 隔离加载类
- 修改类的加载方式
- 扩展加载源(比如 从机顶盒获取加载源)
- 防止源码泄露
如果需要自定义,继承java.lang.ClassLoader就行,并且重写findClass方法
ClassLoader 类,是一个抽象类,所有的类加载器,除了启动类加载器,都继承自classloader
获取ClassLoader的方法:
clazz.getClassLoader
Thread.currentThread().getContextClassLoader
ClassLoader.getSystemClassLoader()
DriverManager,getCallerClassLoader() 获取调用者的类加载器
双亲委派机制(重点)
java对class文件使用的是按需加载的方式,当收到类加载请求的时候,不会立刻使用当前类加载器加载,而是会向上委托父类的加载器进行类加载,父亲加载失败再让子类自己处理(甩锅机制,儿子干不了的都找爸爸,爸爸干不了的才让儿子干,或者有点像孔融让梨)
这样做的好处:防止自己写一个java.lang.String 等等的类替代掉核心的api,顺便一说 就算是自定义类 放在java.lang包下,比如java.lang.XxxClass 也是无法运行的
沙箱安全机制
在加载类的时候如果有核心API ,那么会加载核心API,不会加载自定义同名的类
判断两个类是否相同的必要条件
- 类名、含包名必须完全相同
- 类加载器(ClassLoader对象)必须完全相同
类的主动使用和被动使用
运行时数据区结构
- 堆、方法区(线程之间共享)
- 栈、本地栈(每个线程一个)
红色的是进程共享,灰色是线程间共享
再来回顾一下线程:
- 线程是程序里的运行单元
- 每个线程都和操作系统的本地线程直接映射
- 当监理一个java线程准备好了之后,操作系统的本地线程也同时创建,线程执行中止后,本地线程也会回收。
- 一旦本地线程初始化成功,就会调用java线程中的run()方法
安全点是在程序执行期间的所有GC Root
已知并且所有堆对象的内容一致的点
【04】PC寄存器
Program Counter Register 程序技术寄存器,和CPU寄存器,数据结构类似
PC寄存器用来存储指向下一条指令的地址,也就是下一条即将要执行指令的代码,由执行引擎读取下一条指令
有点像程序执行时候的游标,程序的顺序、分支、循环、异常、线程等基础功能都需要以来计数器完成
唯一没有OutOfMemoryError
执行引擎会到指令地址,取出对应的操作指令,然后执行操作
为什么要存储字节码指令地址呢?
因为CPU在不停切换各个线程,切换回来之后就需要从哪里开始继续执行
JVM字节码通过寄存器知道下一条应该执行哪一条指令
为什么PC寄存器每个线程私有?
- 各个线程是轮流抢CPU资源执行(并发执行)所以每个线程都需要一个寄存器,CPU在线程之间高速切换
【05】 虚拟机栈
【复习】使用虚拟机栈的好处:跨平台(基于寄存器的不能跨平台),指令集小,编译器容易实现。缺点是性能下降,同样功能需要更多指令。
堆和栈的区别:
- 栈是运行时的单位
- 堆是存储的单位
局部变量在栈空间存储,仅限于基本数据类型,对于引用数据类型,栈里面仅存放引用地址。
栈管运行,堆管存储,用一道菜来表示,有点像佛跳墙,左侧是栈,右侧是堆,真正用来存储。
Java虚拟机栈
虚拟机栈是什么?早期也叫java栈,每个线程创建的时候都会创建一个虚拟机栈,内部保存一个个栈帧,,对应着一次次的java调用。
虚拟机栈是线程私有的。一个线程对应着一个虚拟机栈,栈的生命周期和线程一致。一次压栈可以压进好多方法的变量代表一个栈帧。
- 局部变量 vs 成员变量
- 基本数据变量 vs 引用类型变量(类、数组、接口等)
栈的生命周期和线程一致
(虚拟机栈优点)
- 栈的访问速度仅次于程序计数器
- JVM直接对java栈的操作只有两个
- 每个方法执行,伴随着入栈、压栈
- 执行结束后,出栈
- 对于栈来说 不存在垃圾回收 但是存在OOM,爆栈会提示stackoverflowerror
1 | public static void main(String [] args){ |
这个是最简单的爆栈演示。
-Xss size 可以指定栈大小 例如-Xss1024k -Xss1m -Xss1048576
栈中有什么?
栈中的数据以栈帧为基本单位存储
每个执行方法对应一个栈帧。
栈帧是一个数据区块,维护着一个数据集,维系各种数据信息。
栈有先进后出的特性,一个时间点上只有一个活动的栈帧,只有当前活动的栈帧是有效的,称为当前栈帧(current stack frame)
- 不同线程不能混用栈帧。
- 栈帧遇到return 或异常会被弹出
每个栈帧中存储着:
- 局部变量表(local var 局部变量表,局部变量数组或本地变量表)
long 和 double 8个字节,一个字节有8位 所以一共是64位(下图 index直接从3调到5)
Slot是可以重复利用的,当一个变量过了作用域之后,局部变量表可以重复利用分配给后面的 变量,达到节约空间的作用、
非静态的方法有this,存在局部变量表index0的位置,静态方法没有this。
静态类成员变量、实例变量和对象里面的局部变量的区别?
- 静态类变量在prepare阶段赋值0,在init阶段,再给类变量显式赋值
- 实例变量在对象创建的时候在堆里面分配变量空间,并进行默认赋值
- 局部变量在使用前,必须进行显式赋值,否则编译不通过(比如 int a; a++;)
局部变量表中的变量是重要的垃圾回收根节点,只要是局部变量表中直接或间接引用的对象都不会被垃圾回收
- 操作数栈 (表达式栈)
每一个栈帧除了局部变量表外,还包含一个后进先出的操作数栈,用于执行操作数操作(就是逆波兰表达式的方式)
入 1 入2 add →入3
需要注意:返回值要压入操作数栈
(bipush占两行字节码指令byte本身只占一行) 就算是int 但是赋值的值没有大于byte的值 也是用的bipush,不是整数的push 整数是大于128才用这个push(当int取值-15采用iconst指令,取值-128127采用bipush指令,取值-3276832767采用sipush指令,取值-21474836482147483647采用 ldc 指令。)
就是这个操作数栈 可以解释 i+++++i
- 栈顶缓存
动态链接 (指向运行时常量池的方法引用)
- 这一块、包括下面的方法返回地址以及一些附加信息又被叫做帧数据区
- 简单来说就是栈帧里 指向了栈帧外的,位于字节码文件里面的运行时常量池的地址
为什么需要常量池呢?常量池提供了一些符号和常量,便于指令的识别。
方法返回地址
多态的时候就会出现动态链接,比如狗和猫都实现了动物里面的eat方法,但是调用animal.eat(),具体调用的方法还是需要到调用的时候才能确定。
java中的方法有虚方法的特征
(静态 私有 final 实例 父类)
类加载阶段会创建一个虚方法表,存放着不能确定具体调用对象的虚方法。具体调用的时候还要查找,如果查找不到,或者没有访问权限,会报illegalAccessError
java是静态+半动态语言,因为lambda表达式有了一部分动态的特性,像js、python就是纯动态的语言,具体来说就是var name=”xxx”; 而不是必须得String
异常的处理:
正常栈帧执行完成的时候:通过PC计数器调用下一条指令
异常退出的时候,查表(异常处理表)
表表示在4-16行处理异常,去第19行处理。
- 一些附加信息
五道面试题
- 举例栈溢出的情况(StackOverflowError)递归
- 通过调整栈大小,就能保证不出现溢出吗?不能
- 垃圾回收是否会涉及虚拟机栈?不会
PC寄存器 | 虚拟机栈 | 本地方法栈 | |
---|---|---|---|
Error | 无 | 有 | 有 |
GC | 无 | 无 | 无 |
- 分配的栈内存越大越好吗?不是的,整个内存空间有限的,栈空间越大,栈的总数就越少。
- 方法中定义的局部变量是否是线程安全的?(线程安全定义:如果只有一个线程可以操作这个数据,那他就是线程安全,反之如果多个线程可以操作数据,那他就是线程不安全的)答:是线程安全的,因为栈帧是每个线程自己有自己的一份。就算是静态的代码,比如:
1 | Integer bbb=0; |
这里 这个i 就是线程安全的,每个调用它的线程都会在自己的栈帧里创建一个i。
1 | public static void method2(StringBuiler sb){ |
这里传入的sb可能是存在线程不安全的,毕竟他不是局部变量
1 | public static StringBuiler method3(){ |
这个可能是存在线程不安全的问题!因为变量返回出去之后可能被多个线程调用(比如别的线程调用的method2)
1 | public static String method3(){ |
这个就是线程安全的,因为返回的不是SB 而是String,String类是线程安全的
总体来说就是生命周期只在方法内部的就是安全的
【06】本地方法接口
什么是本地方法
简单来说,一个native method 就是一个java调用非java代码的接口,native method由非java方法实现,初衷就是融合C和C++程序。native代码在java类里没实现,就像抽象方法,毕竟不是用java写的
使用native的目的:
- 和外部文件交互
- 和操作系统交互
- 和Sun交互(java底层解释器用C写的)
最常见的:Object.getClass();
【07】本地方法栈
本地方法栈:管理本地方法的,是线程私有的。
允许被实现成固定或可动态扩展的内存大小
虚拟机栈和本地方法栈的区别
- 虚拟机栈:管理java方法的调用
- 本地方法栈:管理本地方法的调用
本地方法:用C语言实现
如果不打算调用C语言,可以不用本地方法栈,在Hotspot中,本地方法栈和虚拟机栈合二为一。
【08】 堆
栈管运行,堆管存储。堆是运行时数据区最大的一块。堆是进程唯一的(一个进程对应一个JVM实例),堆在物理上可以是不连续的内存空间,但是在JVM里面视为是连续的。堆在JVM启动的时候创建,他的空间大小同时确定,他的内存大小是可以调节的。
围观jvm运行的工具:jvisualvm.exe
- 堆分为年轻代和老年代(分代思想)
- 堆有内存大小,会OOM
- Minor GC 、 Major GC 、 Full GC
- 内存分配策略
- 为对象分配内存(TLAB)
- 堆空间的参数设置(需要背)
堆空间的划分
JDK7之前分为新生代,养老代和永久区
JDK8之后堆内存在逻辑上,新生区,养老区和元空间
新生区等价于新生代,这个划分方法是逻辑划分
永久区 元空间暂时不是堆的内容,暂时不被堆的设置影响到。
7和8的区别就是永久区→元空间
堆空间大小设置
-Xmx 最大内存大小 (默认是物理内存1/4)
-Xms 表示堆区起始内存 (默认是物理内存1/64)
查看的:Runtime.getRuntime().maxMemory();
建议初始堆内存和最大堆内存设置成一样的,防止内存波动(开辟内存会浪费时间)
查看设置的参数:
- jps → jstat -gc 进程id
- -XX: +PrintGCDetails
年轻代和老年代
Eden:伊甸园。java对象最开始创建的位置
几乎所有的java对象都是在Eden区被new出来的 。如果Eden区放不下,会放到老年代
根据统计,绝大部分的Java对象销毁都在新生代进行
对象分配过程
伊甸园区满了:进行Young GC 也叫Minor GC
→ 把伊甸园区放到S0
→ 第二次GC: S0放到S1,伊甸园也放到S1,清空S0 S0到S1的时候对象的age会+1
→ 第三次GC: S1放到S0,伊甸园也放到S0,清空S1 S1到S0的时候对象的age会+1
。。。
→age达到15之后,对象会promotion(晋升) 也就是说15次GC这个对象还活着,晋升之后这个对象放到老年区
伊甸园区满了,进行YoungGC ,但是幸存者区满了呢?答:会按照一定规则放到老年代。
也有可能伊甸园区的对象,进行GC 直接就放到老年代(自带超级赛亚人光环)
幸存者两个区,轮流使用,第一次用0区,第二次用1区 ,也叫from区和to区,但是from和to和 S0 S1不是一一对应的,下一个就是to(或者说 执行完GC之后,空的就是to区)
口诀:S0 S1复制之后有交换,谁空谁是To区
关于垃圾回收:频繁动新生代,很少收集养老代,几乎不在永久区/元空间收集
常用调优工具
JVisualVM
Jconsole
JDK命令行
GCViewer
JProfiler
【重要】 Minor GC、Major GC 和FullGC
并不是每次GC时,都对EDEN区、幸存者区以及老年代区
JVM调优的目的:让GC次数少一些
GC的时候会让用户线程暂停(stop the world)
Minor GC:只是新生代的垃圾收集
Major GC:只是老年代的垃圾收集 很多时候Major GC和Full GC混淆使用,需要区分是老年代回收还是整堆回收
Full GC: 收集整个java堆和方法区的垃圾收集
年轻代EDEN区满触发MinorGC Survivor满不会触发GC 大部分Java对象具有朝生夕死的特性,所以MinorGC 非常频繁,回收速度也比较块 MinorGC会触发STW,暂停其他用户线程,等到垃圾回收结束,用户线程才恢复运行。
MajorGC触发的时候经常伴随一次Minor GC 但是不一定,MajorGC 的速度比Minor GC慢十倍以上,STW时间更长。如果MajorGC之后内存还是不足,就OOM 了
FULL GC一般要避免
Xms9m Xmx9m -XX -PrintGCDetails
跑一个死循环的程序,会报OOM
打印日志,可以发现OOM之前有一次Full GC
堆空间分代思想
为什么要给Java堆分代?不分代就不能工作了吗?
JDK8里永久代变成元空间
不分代完全是可以的,分代唯一理由就是优化GC性能
内存分配策略
动态对象年龄判断:一个age的对象占s区超过一半时,来回在from to区倒腾对象也是浪费次数,不如直接去老年区,留着占s区地方
大对象直接进老年代:
byte [] bytes=new byte[1024 * 1024 * 20]; //20m
内存分配规则:
- 优先分配到Eden
- 大对象直接分配到老年代(避免程序中出现过多大对象)
- 动态对象年龄判断
- 空间分配担保
为什么会有TLAB?
堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
什么事TLAB?JVM给每个线程分配的一个私有区域,分配内存时,可以避免一系列非线程安全问题
TLAB只占有EDEN空间1%,但是JVM把TLAB作为内存分配的首选