jvm
常用的Java编译器指令流是 基于栈的指令集 另一种指令集是 基于寄存器指令集 JVM运行时环境包含:线程共享的 方法区、堆区 、元数据空间 以及 线程独享的 虚拟机栈、本地方法栈、程序计数器
-
基于栈指令集(在操作系统上运行的JVM,如:HotSpot)
-
设计简单,适用于资源受限系统
-
避开寄存器分配问题,使用零地址指令(只针对栈顶操作)
-
大部分零地址指令,执行过程依赖于执行栈,指令集小,指令多(每8位对其),编译器实现容易
-
与硬件无耦合,移植性好
-
-
基于寄存器指令集(在硬件上运行的JVM,如:Android的Davlik)
-
指令集完全依赖于硬件,移植性差(每16位对其)
-
直接操作硬件性能更高效
-
使用多地址指令,指令集多,指令
-
JVM 生命周期
虚拟机启动
启动通过加载 引导类加载器 创建一个初始类来完成,这个初始类由虚拟机具体实现决定
虚拟机执行
程序执行时候,JVM开始运行;程序停止,JVM停止。
虚拟机退出
正常退出;遇到异常/错误终止;线程调用Runtime/System的exit方法;使用JNI的API卸载JVM
类装载过程
ClassLoader只负责class文件加载到内存,具体运行由执行引擎处理;加载的类信息存放在方法区;类装载过程:加载(Loading)->验证(verification)->准备(Preparation)->解析(Resolution)->初始化(Initialization),其中 验证、准备、解析 统称为 初始化 阶段
加载
将字节码文件存储结构加载到内存中,转化为方法区运行时数据结构,并在内存中生成一个代表该类的java.lang.Class
对象,作为这个方法区访问数据的入口
方法区在JDK7以及以前实现通过 永久代;在JDK8之后版本叫 元数据空间
加载.class文件
-
从本地系统直接加载
-
通过网络获取
-
从压缩包获取
-
运行时计算生成,如:动态代理
-
由其它文件生成,如JPS
-
从加密文件获取
链接
验证
文件格式验证、元数据验证、字节码验证、符号引用验证;
.class字节码文件以ASCII字节码的
cafebabe
开头
为 类变量 分配内存并且设置该变量的默认初始值
仅会将
static
修饰类属性初始化,而static final
修饰类属性在该阶段就进行会 宏替换 初始化;static final属性和字符串常量放在ConstantValue属性中,在准备阶段初始化,其他类属性在<cinit>阶段初始化
解析
将常量池中的符号引用转换为直接引用,该过程实际是在初始化之后才进行的
符号引用就是描述引用的符号,如:#1、#2等 直接引用就是指向目标的指针、相对偏移量或者一个间接定位目标的句柄
初始化
初始化阶段就是执行类构造器<clinit>()的过程;该方法不能被定义,是javac编译器收集类中类变量赋值动作和静态代码块语句合并自动自动生成的;虚拟机保证<clinit>对类的加载过程加锁
类加载器
Bootstrap Class Loader->Extersion Class Loader->System Class Loader->UserDefined Class Loader
类加载器之间是包含关系,根加载器由底层C实现
根加载器只加载rt.jar、resources.jar、sun.boot.class.path包名java、javax、sun开头的包
Extersion Class Loader使用java编写;是sun,misc.Launcher$ExtClassLoader实现;加载java.ext.dirs和jre/lib/ext目录下的类库
获取加载器方法
-
Class.forName("").getCLassLoader();
-
Thread.currentThread().getContextClassLoader();
-
ClassLoader.getSystemClassLoader();
双亲委派机制
-
类加载器收到类加载请求,先交给父类加载器执行
-
如果父类加载器还存其父类,则进一步向上委托,直到最终到达顶层根加载器
-
如果父类加载器无法加载,则会依次向下由子类加载器尝试加载
如果在类被上层加载器执行了,则无论如何也不会交由下层加载器执行
JVM认为两个类相同条件:1.完整类名相同;2.类加载器相同
如果一个类是由用户类加载器执行,则该类加载器的引用作为类信息存放在方法区
Tomcat等服务器程序一般都不遵守双亲委派模型,因为服务器软件会加载多个服务器程序,为了实现类隔离
继承java.lang.ClassLoader类,并且重写loadClass方法,可是打破双亲委派模型;重写findClass方法,遵守双亲委派模型,因为默认的loadClass中先调用父类加载,实现了双亲委派模型,在这两种方法将类信息读入到byte数组中,并调用defineClass方法生成类
类的使用
类的加载分为 主动使用 和 主动使用;区别在于是否会初始化
主动使用:
-
创建实例
-
访问静态变量
-
反射
-
初始化子类
JVM内存结构
在JVM中每个线程与操作系统本地线程存在一一对应关系,本地线程初始化完成后会调用Java线程中的run方法
程序计数器(PC寄存器)
JVM中的程序计数器是对物理PC寄存器的一种软件模拟;用来存储下一条指令的地址;每个线程都有一个PC寄存器,用来存储当前线程当前方法的的JVM指令地址,如果是native方法则是未指定值;它是唯一一个没有OutOfMemoryError
堆和方法区(元数据空间)会有垃圾回收;栈、堆、方法区(元数据空间)都可能发生溢出
虚拟机栈
栈是运行时单位,堆是存储单位;每个线程创建时都会创建一个虚拟机栈,栈中保存栈帧,一个栈帧对应一个Java方法;存储方法的局部变量(8种基本类型的值和引用类型对象的地址);栈上数据不存在线程安全问题
通过-Xss 设置栈大小
栈帧
栈帧对应一个方法,栈帧中存储 局部变量表、操作数栈、动态链接(指向运行时常量池的方法引用)、方法返回地址、附加信息;其中 动态链接、方法返回地址、附加信息 又叫帧数据区
栈会抛出异常:1.stackoverflow(栈溢出):方法调用太多超出栈容量(大量递归,java没有进行尾递归优化);2.oom:多线程 环境下,每个线程栈的容量太大
局部变量表
定义为一个数组,存储方法的参数以及方法体内的局部变量,存储单元叫 槽;容量在编译期就确定,并保存在Code属性的maximum local variables数据项中;如果是构造方法/实例方法则会在index为0的位置存放实例地址
局部变量表的引用也是垃圾回收根节点之一
double、long占用两个槽;其它数据类型占用一个槽
操作数栈
数组实现,也叫表达式栈
动态链接
每个栈帧都包含一个指向该栈帧所属方法的引用(保存在常量池中)
非虚方法 在编译期就能缺点,运行期间不可变,有静态方法、私有方法、final方法、实力构造器、父类方法
invokestatic:调用静态方法 invokespecial:调用实例构造方法、私有方法、父类方法 invokevirtual:调用虚方法 invokeinterface:调用接口方法 invokedynamic:动态解析需要调用的方法然后执行,lambda表达式
静态语言判断自身变量的类型信息,在编译期确定;动态语言判断变量值的类型信息,在运行期确定
堆
一个JVM实例只有一个堆内存,在JVM启动时被创建,空间大小也就确定了;物理上不连续但逻辑上连续;所有线程共享堆区,Eden区中还可以划分线程私有的缓冲区(TLAB区);JDK8以后,堆=新生代+老年代
几乎所有的对象和数组都会在堆上分配,但是通过 逃逸分析 和 标量替换 会将对象在栈上分配
-Xms
设置堆区起始内存大小(新生代+老年代);-Xmx
设置堆区最大内存大小(新生代+老年代);一旦堆区内存超过-Xmx
设置大小会抛出OutOfMemoryError
异常;一般将两个参数设置为一样大小,是为了防止GC频繁的扩容和释放默认新生代中Eden和s0、s1比例是8:1:1;新生代和老年代比例是1:2;一般元数据空间不算在堆中,所以新生代占堆的1/3
会发生OOM:1.内存溢出:检查对象生命周期,减少运行时占用内存;2.内存泄漏:不会使用的对象一直占用内存,查找泄漏对象引用链
对象分配过程
-
new对象先放置在Eden区
-
当Eden区满,又要创建对象时进行MinorCG(YoungGC)将Eden和Survivor区回收,再将对象放置到Eden区(Survivor区满不会触发GC)
-
将Eden区剩余对象转移到to区,设置次数1,之后将to区改为from区
-
再次MinorCG的时候,Eden区域和from区的对象转移到to区,并且转换to区和from区
-
当转移次数到15次时,对象被放到老年代
-
Eden经过CG后一定为空,如果超大对象内存超过了Eden区大小,会进行FullGC尝试再老年代分配,如果老年代也放不下,则会抛出OOM
-
Survivor放不下对象会晋升到老年代(默认开启分配担保)
方法区(非堆区)
JDK8以前叫永久代,JDK8以后叫元空间,属于堆外内存,是一片线程共享区域,可以物理上不连续;加载类/jar包过度/反射类过多会抛出OOM:Metaspace异常;用于存储 类型信息、常量、JIT编译后的代码缓存
方法区和永久代不等价,只有HotSpot,JDK1.8以前才通过永久代实现方法区,JDK1.8后才用元空间实现方法区;永久代使用虚拟机设置的内存,元空间使用本地内存
字符串常量、静态变量在JDK1.7之后就移到堆区保存
方法区回收:常量池废弃的常量和不再使用的类型;常量包含字面量(文本字符串、final常量等)和符号引用
java虚拟机规范没有明确方法区可否GC(JDK11的ZGC方法区就不支持类卸载)
加载的类数量太大也会发生OOM:反射、代理、JPS
运行时常量池
方法区中包含了运行时常量池,就是class文件中的常量池通过加载器在内存中创建的结构,类似 符号表 但是具备动态性,可以在运行时向常量池增删数据,如:String的intern方法
GC
Java程序启动参数-XX +PrintCGDetails
显示GC详细信息
部分回收:
-
新生代回收(Minor GC/Young GC):只是新生代(Eden/s0/s1)的垃圾回收
-
老年代回收(Major GC/Old GC):只有CMS GC单独收集老年代;
很多时候Major GC和FullGC 混淆,主要分辨是部分回收还是整堆回收
-
混合回收(Mixed GC):回收整个新生代和部分老年代;目前只有G1 GC支持这种行为
整堆回收(Full GC):回收整个Java堆和方法区(元空间)
新生代回收
Eden区内存不足会触发Minor GC回收Eden区、s0、s1;当s0、s1区满不会触发GC;Minor GC会触发STW暂停其它用户线程,等垃圾回收完,用户线程才恢复
老年代回收
Major GC和Full GC都会触发老年代垃圾回收;老年代内存不足先触发Minor GC,如果内存还不足则触发Major GC,如果Major GC后内存还不足则OOM;Major GC速度比Minor GC慢10倍以上,STW时间更长
整堆回收
-
System.gt()
-
老年代空间不足
-
方法区空间不足
-
通过Minor GC后进入老年代的平均大小大于老年代可用内存
-
由Eden区域、from区向to区复制对象,对象大小大于to区可用内存,且大于老年代可用内存
内存分配策略
-
优先分配在Eden
-
大对象(超过Eden区大小)直接分配在老年代,如:字符串、数组等
-
长期存活的对象分配的老年代(年龄大于15)
-
如果Survivor区中相同年龄的对象综合大于Survivor空间的一半,则直接进入老年代
TLAB(Thread Local Allocation Buffer)
堆区完全共享会容易产生线程不安全问题,如果采用加锁解决会影响性能;因此,在Eden区给每个线程分配一块线程独有区域,多线程分配内存,使用TLAB区域能够避免线程安全问题提升效率避免加锁,通过-XX:UseTLAB
设置是否开启,通过-XX:TLABWasteTargetParcent
设置TLAB区占Eden区大小;当TLAB空间分别配失败,则使用加锁保证原子性同时,在Eden区分配内存
逃逸分析
栈上分配对象
通过逃逸分析,如果一个对象引用只在方法内部,则没有发生逃逸,就会把对象放在栈空间;通过-XX:PrintEscapeAnalysis
查看逃逸分析筛选结果
同步锁消除
动态编译 同步代码块 如果锁对象没有逃逸方法(只被一个线程访问而没有发布到其它线程),则JIT编译器编译成本地方法后会消除 同步代码块(字节码文件中还是存在),这个过程叫 锁消除
标量替换
在JIT阶段(编译成本地代码)如果一个对象没有逃逸出方法,则会把对象分解成基本类型变量操作
标量:基本类型;聚合量:对象,结构体等
Java Oracle HotSpot所有对象还是分配在堆上,逃逸分析只会进行标量替换,并不会在栈上分配对象
intern字符串缓存和静态变量没有被转移到元数据区,而是直接在堆上分配
字符串常量
JDK7将StringTable移动到堆空间,因为永久代回收率低,fullGC才会触发,而fucllGC是老年代不足时,永久代也不足时才会触发,而开发过程,大量字符串创建一直占有内存,导致方法区空间不足
对象内存结构
创建对象过程
-
加载类元信息
-
为对象分配内存
-
处理并发问题
-
属性默认初始化
-
设置对象头信息
-
属性显示初始化
对象内存布局
-
对象头
-
运行时原数据(Mark Word):哈希值、GC分代年龄、锁状态标志位、线程持有锁、偏向锁ID、偏向时间戳
-
类型指针(不是所有对象都有):指向类元数据(InstanceKlass),确定该对象所属类型
-
*如果是数组,还要记录数组长度
-
-
实例数据:父类变量在子类变量之前,相同宽度变量分配在一起
-
对象填充
访问对象方式
-
句柄访问:栈帧的局部变量表中的对象引用存储的是 堆中句柄池中的对象数据实例指针、对象类型数据实例指针组成的结构,在通过该结构访问堆中对象实例数据和方法区的对象类型信息
-
直接指针:栈帧的局部变量表中的对象引用存储的是 堆中对象实例数据,并且该对象实例数据中保存了一份指向方法区对象类型数据的指针
分派
分派是指 定位要执行的方法,编译期分派叫做 静态分派,运行期分派叫做 动态分派;宗量是指方法调用者和方法执行的参数,根据分派宗量数量分为 单分派 和 多分派,Java是静态多分派,动态单分派语言(运行期间只根据参数决定方法调用WW)
直接内存
直接内存不由JVM管理,默认与-Xmx
设置大小一样;在JVM监控中不会显示出直接内存占用情况,但是超过直接内存大小也会抛出OOM异常;NIO可以使用直接内存
JIT编译器
将热点字节码编译成本地代码并且缓存起来
机器在热机状态负载要大于冷机状态,因为热点代码本地化编译了并且有缓存,可直接运行;而冷机状态需要初始化加载类消耗大量资源
热点代码,可以基于方法调用次数统计(默认1000次),也可以基于循环调用次数统计;如果超过一段时间没有超过阈值,则计数会衰减一半
客户端模式的JIT编译器C1,编译速度快;服务器模式的JIT编译器C2,激进优化,代码执行效率高;64位操作系统只能是服务器模式
C1优化策略
-
方法内联:方法内调用其他方法,则直接编译到方法内,不会生成新的栈帧
-
去虚拟化:对唯一的实现类内联
-
冗余消除:运行时将不会用到的代码折叠
C2优化策略(逃逸分析)
-
标量替换:用标量值代替聚合对象属性值
-
栈上分配:未逃逸对象分配在栈上
-
同步消除:消除synchronized
StringTable
String在JDK1.8以前底层都是char数组,在JDK1.9以后底层是byte数组;字符串常量池中不会存储相同字符串,底层是一个固定大小Hashtable默认长度60013,最小值1009;字符串常量池在新生代
JDK6以及以前字符串常量保存在永久代,JDK7以及以后保存在堆空间
字符串常量和常量拼接,在编译器优化成一个完整字符串
如果字符串拼接变量,则会在堆空间new StringBuilder拼接(final修饰的常量则不会)并且最后调用toString输出(调用new String(char[] value),拼接后的字符串没有添加到常量池)
StringBuilder.toString();底层调用new String(char[] value)不会将字符串添加到堆字符串常量池中
调用字符串intern方法,如果字符串常量池不存在,则JDK6会将字符串添加到常量池并返回;JDK7以后则会在常量池中创建一个指向堆(非常量池)的引用并返回
new String返回堆中非常量池对象,string.intern()返回堆中常量池对象
如果将字符串数组转换为字符串,通过new String(char[]).intern()方法调用,可以返回常量池中字符串,并且在GC时将堆(非常量池)中字符串回收
String str1 = new String("a")+new String("b");
String str2 = "ab";
System.out.println(str1==str2); //false
String str1 = new String("a")+new String("b");
str1.intern();//jdk7以后在常量池放引用指向堆中字符串对象
String str2 = "ab";//ab就是常量池中的引用,而不会在常量池创建新字符串对象
System.out.println(str1==str2); //jdk7以后,true
String str1 = new String("a")+new String("b");
String str2 = "ab";
str1.intern();//常量池中已经存在对象,则不会创建引用,会在堆区和常量池存在两份相同的字符串
System.out.println(str1==str2); //false
GC
垃圾标记
判断对象是否存活有两种算法:引用计数算法 和 可达性分析算法;JVM在1.7以前触发GC后,如果创建对象内存不足,则会将Eden区对象移动到老年代,JVM1.8以后则会将新对象分配到老年代
引用计数算法,回收没有延迟,但循环引用无法删除;Python使用,通过手动解除或者弱引用解决 循环引用
引用到堆内存对象,但本身不在堆内存,则作为GCROOT,如:栈中引用、方法去静态属性引用、方法区常量属性引用、JNI引用
GC时候,对象第一次被清除会调用finalize方法,如果重新将对象(this)赋给强引用链,则此次GC不会删除该对象;下一次GC不会再次调用finalize方法
标记阶段,标记非垃圾对象
system.gc()调用full GC
所有的垃圾回收器都有stw;stw后台自动完成,被stw中断的线程GC后恢复
安全点:只有在安全点/安全区域才会进行GC,一般选择执行时间长的指令作为安全点,如:方法调用、循环跳转、异常跳转
GC对于规整空间使用 指针碰撞;不规整空间使用 空闲列表 (并不清除只是覆盖)
对象引用
强引用
只要引用关系存在,垃圾回收器不会回收引用对象
软引用(SoftReference)
引用存在时,GC内存不足时会被回收,常用于实现内存敏感的缓存(MyBatis)
弱引用(WeakReference)
引用存在时,GC进行时回收对象
虚引用(PhantomReference)
无法通过虚引用获取对象实例,设计虚引用唯一目的是跟踪GC,在创建软引用必须提供一个引用队列,当GC以后会把虚引用放入引用队列
垃圾回收器
对象分配:
-
规整对象通过指针碰撞,即:通过一个指针划分已被分配空间和未分配空间
-
不规整对象通过空间列表记录未分配空间
在最大吞吐量优先情况下,降低停顿时间
-
串行回收器:Serial(新生代GC)、Serial Old(老年代GC)
-
并行回收器:ParNew(新生代GC)、Parallel Scavenge(新生代GC)、Parallel Old(老年代GC)
-
并发回收器:CMS(老年代GC)、G1
串行回收
在Client模式下,新生代采用SerialGC,采用串行标记复制算法;老年代使用Serial Old GC,采用串行标记压缩算法;单核CPU没有线程切换开销;一般WEB程序不使用这类回收器
并行回收(JDK14移除)
新生代采用Parallel Scavenge,采用并行标记复制算法,可以自适应调节内存分配;老年代使用Parallel Old GC,采用并行标记压缩算法
JDK8默认新生带用Parallel Scavenge,老年代Parallel Old
CMS
-
初始标记:stw仅标记GCROOT直接关联对象,速度非常快
-
并发标记:不停顿,从GCROOT开始遍历整个对象
-
重新标记:stw修正并发标记阶段产生变动的标记记录
-
并发清除:标记清除算法
弊端:产生内存碎片;并发占用一部分线程导致程序变慢;并发清除阶段无法处理新垃圾,可能导致FullGC
最大化GC吞吐量:Parallel Scavenge+Parallel Old
最小化GC延迟:ParNew+CMS+Serial Old(备案)
G1
G1是并行回收器,将堆内存分割为不同的区域(物理上不连续),使用不同的区域表示Eden、s0、s1、老年代;G1计算每个Region垃圾堆积价值的大小,后台维护一个优先列表,每次回收价值最大的Region,是JDK9以后的默认回收器;Region之间是复制算法;G1可以采用引用线程帮助垃圾回收;每个Region通过指针碰撞分配空间;老年代和新生代不再是物理隔离
垃圾回收过程可能对象中引用的对象在不同的区域,通过RemembberedSet,在写入引用类型数据时,会产生一个写屏障中断操作,然后检查写入引用,如果和当前引用在不同的区域,则会记录到RemembberedSet中,避免垃圾回收遍历GCROOT时候的全表扫描,也不会遗漏
背压是指在异步场景中,被观察者发送事件速度远快于观察者的处理速度的情况下, 一种告诉上游的被观察者降低发送速度的策略,简而言之,背压是流速控制的一种策略。
背压策略的一个前提是异步环境,也就是说,被观察者和观察者处在不同的线程环境中。 背压(Backpressure)并不是一个像flatMap一样可以在程序中直接使用的操作符,他只是一种控制事件流速的策略。
在RxJava的观察者模型中,被观察者是主动的推送数据给观察者,观察者是被动接收的。而响应式拉取则反过来, 观察者主动从被观察者那里去拉取数据,而被观察者变成被动的等待通知再发送数据。
实现背压(流速控制):1.过滤掉一部分事件;2.缓存一部分事件;3.响应式拉取(在钩子函数中处理完成通知被观察者发送下一个事件)
// 命令式编程
int a=1;
int b=a+1;
System.out.print(“b=”+b) // b=2
a=10;
System.out.print(“b=”+b) // b=2
// 响应式编程,传递变化,构建关系,而不是执行某种赋值命令
int a=1;
int b <= a+1; // <= 符号只是表示a和b之间关系的操作符
System.out.print(“b=”+b) // b=2
a=10;
System.out.print(“b=”+b) // b=11