JVM内存模型定义
JVM不只承担了Java字节码的分析(JIT)和执行(Runtime),同时也内置了自动内存分配管理机制
内存模型图解
堆
堆是jvm内存中最大的一块内存空间,该空间被全部线程共享,几乎全部的对象和数组都被分配到了堆内存中: 堆被划分为新生代和老年代,新生代划分为Eden和Survivor区,Suvivor是由From Survivor和To Survivor组成
java6中,永久代在非堆内存去
java7中,永久代的静态变量和运行时常量池被合并到 了堆中
java8中,永久代被元空间取代了,元空间存储静态变量和运行时常量池跟java7永久代同样儿,都移到了堆中中
程序计数器
是一块很小的内存空间,主要用来记录各个线程执行的字节码地址 例如:分支,循环,跳转,异常,线程恢复都能依赖于计数器
注意: 每一个线程有一个单独的程序计数器来记录下一条运行的指令
方法区
在HotSpot虚拟机使用永久代来实现方法区,在其余虚拟机中不是这样的,只是在HotSpot虚拟机中,设计人员使用了永久代实现了JVM规范的方法区
方法区主要用来存放已被虚拟机加载的类相关信息 : 类信息,运行时常量池,字符串常量池(class、运行时常量池、字段、方法、代码、JIT代码等 )
类信息包括了类的版本,字段,方法,接口和父类等信息
JVM执行类加载步骤:加载,链接(验证,准备,解析三个阶段),初始化 ,在加载类的时候,JVM会先加载class文件,在class文件中除了有类的版本,字段,方法和接口等描述信息外,还有一项信息是常量池,用于存放编译期间生成的各类字面量和符合引用
字面量包括字符串(String a = "hello"),基本类型的常量(final修饰的变量)
符号引用包括类和方法的全限定名(如String为Java/lang/String),字段的名称和描述符以及方法的名称和描述符
当类加载到内存中后,JVM就会将class文件常量池中的内容存放到运行时常量池中,在解析阶段,JVM会把符号引用替换为直接引用(对象的索引值)
好比:类中的一个字符串常量在class文件中时,存放在class文件常量池中的
在JVM加载完类后,JVM会将这个字符串常量放到运行时常量池中,并在解析阶段,指定改字符串对象的索引值
运行时常量池是全局共享的,多个类中共用一个运行时常量池,class文件中常量池多个相同的字符串在运行时常量池中只会存在一份
方法区与堆空间相似,也是一个共享内存区,因此方法区是线程共享的 ,若是有两个线程试图都访问方法区中的一个类信息,而这个类尚未装入JVM中,那么此时就只容许一个线程去加载它,另外一个线程必须等待
永久代:包括静态变量和运行时常量池,永久代的类等数据
Java7中将永久代的静态变量和运行时常量池转移到了堆中,其他部分则存储在JVM的非堆内存中(当依然在JVM内存中)
Java8中将方法区中实现的永久代去掉,使用元空间替代,而且元空间的存储位置为本地内存(不在JVM内存中,而是直接存在内存中的),以前永久代的类的元数据存储在了元空间,而永久代的静态变量以及运行时常量池跟Java7同样转移到了堆中
元空间:存储的是类的元数据信息:关于数据的数据或者叫作用来描述数据的数据:就是最小的数据单元,元数据能够为数听说明其元素或属性(名称,大小,数据类型等),其结构(长度,字段,数据列),或其相关数据(位于何处,如何联系,拥有者等)
为什么使用元数据区替代永久代
字符串存在永久代中,容易出现性能问题和内存溢出
类及方法的信息等都比较难肯定其大小,所以对于永久代的大小指定比较困难(默认8M),大小容易出现永久代溢出,太大则容易致使老年代溢出
永久代会为GC带来没必要要的复杂度,而且回收效率偏低
最重要的是Oracle想将HotSpot与JRockit(没有永久代概念)虚拟机合二为一
虚拟机栈
Java虚拟机栈是线程私有的内存空间,它跟Java线程一块儿被建立,当建立一个线程时,会在虚拟机栈中申请一个栈帧,用来保存方法的局部变量,操做数栈,动态连接方法和返回地址等信息,并参与方法的调用和返回
每一个方法的调用都是一个入栈操做,方法的返回则是栈帧的出栈操做
本地方法栈
同Java虚拟机栈功能相似,Java虚拟机栈用来管理java函数调用的,本地方法栈用来管理本地方法的调用,是由C语言实现的
JIT运行时编译(优化Java)
类编译加载执行过程
Java编译到运行过程
类编译
将.java文件编译成.class文件(使用javac命令生成),编译后的字节码文件主要包括常量池和方法表集合这两个部分
常量池主要记录的是类文件中出现的字面量以及符号引用
字面常量包括字符串常量,声明为final的属性以及一些基本类型的属性
符号引用包括类和接口的全限定名,类引用,方法引用以及成员变量引用(如String Str = "abc",其中str就是成员变量引用)
类加载
当一个类被建立实例或者被其余对象引用时,虚拟机在没有加载过该类状况下,会经过类加载器将字节码文件加载到内存中
不一样的实现类有不一样的类加载器加载,JDK中本地方法类通常由根加载器加载,JDK中内部实现的扩展类通常由扩展加载器实现加载,程序中的类文件则由系统加载器实现加载
在类加载后,class类文件中的常量池信息以及其余数据会被保存到JVM内存的方法区中
类连接: 验证,准备,解析
类初始化
类初始化是类加载的最后一个阶段,初始化时,JVM首先将执行构造器方法,编译器会将.java文件编译成.class文件时,收集全部类初始化代码,包括静态变量赋值语句,静态代码块,静态方法,收集在一块儿成为方法
初始化类的静态变量和静态代码块为用户自定义的值,初始化的顺序和Java源码从上到下的顺序一致
子类初始化时会首先调用父类的()方法,在执行子类的方法
JVM会保证()方法的线程安全,保证同一时间只有一个线程执行
JVM在初始化执行代码时,若是实例化一个新对象,会调用方法对实例变量就行初始化,并执行对应的构造方法内的代码
思考题: 反射中Class.forName()和ClassLoader.loadClass()的区别
装载:经过累的全限定名获取二进制字节流,将二进制字节流转换成方法区中的运行时数据结构,在内存中生成Java.lang.class对象;
连接:执行下面的校验、准备和解析步骤,其中解析步骤是能够选择的;
校验:检查导入类或接口的二进制数据的正确性;(文件格式验证,元数据验证,字节码验证,符号引用验证)
准备:给类的静态变量分配并初始化存储空间;
解析:将常量池中的符号引用转成直接引用;
初始化:激活类的静态变量的初始化Java代码和静态Java代码块,并初始化程序员设置的变量值。
Class.forName(className)方法,内部实际调用的方法是 Class.forName(className,true ,classloader);
第2个boolean参数表示类是否须要初始化, Class.forName(className)默认是须要初始化。
一旦初始化,就会触发目标对象的 static块代码执行,static参数也也会被再次初始化。
ClassLoader.loadClass(className)方法,内部实际调用的方法是 ClassLoader.loadClass(className,false );
第2个 boolean参数,表示目标对象是否进行连接,false 表示不进行连接,由上面介绍能够,
不进行连接意味着不进行包括初始化等一些列步骤,那么静态块和静态对象就不会获得执行
复制代码
即时编译
初始化完成后,类在调用执行过程当中,执行引擎会把字节码转为机器码,而后在操做系统中才能执行在字节码转换为机器码的过程当中,虚拟机中还存在着一道编译,即为即时编译
虚拟机中的字节码是由解释器(Interpreter)完成编译的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为 热点代码
为了提升热点代码的执行效率,在运行时 JIT会把这些代码编译成与本地平台相关的机器码,并进行层次的优化,而后保存到内存中
即时编译器类型
HotSpot虚拟机中,内置了两个JIT,分别为C1编译器和C2编译器,他们的过程不一样
C1编译器是一个简单快速的编译器,主要关注点在局部性的优化,适用于执行时间较短或对启动性能有要求的程序,如GUI应用对界面启动速度有必定要求
C2编译器是为长期运行的服务器端应用程序作性能调优的编译器,适用于执行时间较长或对峰值性能优要求的程序,这两种编译器也被称为Client Compiler和Server Compiler
Java7以前,根据程序特性来选择对应的JIT,虚拟机默认采用解释器和其中一个编译器配合工做
Java7引入了分层编译,综合了C1启动性能优点和C2的峰值性能优点,经过设置参数可强制更改
分层将JVM的执行状态分为5个层次
热点探测:JVM编译优化条件
HotSpot虚拟机的热点探测是基于计数器的热点探测,虚拟机会为每一个方法创建计数器统计方法的执行次数,若是次数超过必定的阈值就认为为热点方法
虚拟机为每一个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter) ,在肯定虚拟机运行参数的前提下,这两个计数器都有一个肯定的阈值,当计数器超过这个阈值就会触发JIT编译
方法调用计数器: 用于统计方法被调用的次数,默认阈值在C1模式下1500次,在C2模式下是1万次,而在分层编译下,将会根据当前待编译的方法数以及编译线程数来动态调整
回边计数器: 用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为回边(Back Edge) ,在不开启分层编译的状况下,C1默认13995,C2默认10700,在分层状况下,将根据当前编译的方法数以及编译线程数来动态调整
创建回边计数器主要目的是为了出发OSR(On StackReplacement)编译,即栈上编译 ,对于一些循环周期比较长的代码段,当循环达到回边计数器阈值时,JVM认为这段是热点代码,JIT编译器就会将其编译成机器语言并缓存,并在该循环时间段内,执行缓存的机器语言
编译优化技术
方法内联
因为调用一个方法一般要经历压栈和出栈:调用方法是将程序执行顺序转移到存储该方法的内存地址,将方法的内容执行完成后,在返回到执行该方法前的位置
这样执行要求执行前保护线程并记忆执行的地址,执行后恢复现场并按照原来保存的地址继续执行该方法调用会缠上必定的时间和空间方面的开销
可是对于方法体代码不大有频繁调用的方法,这个开销就很大了
方法内联的优化就是将目标方法的代码复制到发起调用的方法之中,避免发生真是的方法调用,如kotlin扩展函数中的inline关键字
JVM会自动识别热点方法,并对它们使用方法内联进行优化,可是热点方法并不必定会被JVM作内联优化,若是这个方法太大将不会执行内联操做
热点方法内联优化能够有效提升系统性能,咱们有一下方法提升:
经过设置JVM参数来减少热点阈值或增长方法体阈值,可是须要占用更多的内存
在编程中,避免在一个方法中写大量代码,习惯使用小方法体
尽可能使用final,private ,static关键字修饰方法,编码方法由于继承,会须要额外的类型检查
逃逸分析
逃逸分析基本行为就是分析对象动态做用域:当一个对象在方法中被定义后,他可能被外部所引用,例如做为参数传递到其余地方中,称为方法逃逸 public static StringBuffer craeteStringBuffer(String s1, String s2) { //sb对象逃逸了
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
public static String createStringBuffer(String s1, String s2) { //sb对象没有逃逸
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
复制代码
使用逃逸分析,编译器能够作一下优化:(Jdk 1.7开始默认开启)
同步省略: 若是一个对象被发现只能从一个线程被访问到,那么对于这个对象的操做就能够不考虑同步
将堆分配转化为栈分配 :若是一个对象在子程序中被分配,要使其指向改对象的指针永远不会逃逸,对象能够在栈上分配而不是堆分配
分离对象或标量替换: 有的对象可能不须要做为一个连续的内存结构存在也能够被访问到,那么对象的部分或所有能够不存储在内存,而是存储在CPU寄存器中
同步省略 (锁消除)
在动态编译同步块时,JIT编译器会借助逃逸分析来判断同步块所使用的锁对象是否只可以被一个线程访问儿没有被发布到其余线程,若是是只能被一个线程访问,则会取消这部分代码的同步,好比在使用synchronized时,若是JIT通过逃逸分析发现并没有线程安全问题,就会作锁消除 public static String getString(String s1, String s2) {
StringBuffer sb = new StringBuffer();//只在当前线程,因此会取消同步操做,锁消除
sb.append(s1);
sb.append(s2);
return sb.toString();
}
public void f () {
Object hollis = new Object();
synchronized(hollis) { //锁方法内部对象,会锁消除
System.out.println(hollis);
}
}
//至关于
public void f () {
System.out.println(hollis);
}
复制代码
栈上分配
Java默认建立一个对象在堆中分配内存的,当对象再也不使用时,则须要经过垃圾回收机制回收,这个过程相对于分配在栈中的对象的建立和销毁来讲,更加消耗时间和性能.这个时候逃逸分析若是发现一个对象只在方法中使用,就会将对象分配在栈上
遗憾的是:HotSpot虚拟机目前的实现致使栈上分配实现比较复杂,暂时没有实现这项优化,相信不久未来会实现的
虽然这项技术并不十分红熟,可是她也是即时编译器优化技术中一个十分重要的手段
标量替换
标量(Scalar)是指一个没法再分解成更小的数据的数据,Java中的原始数据类型就是标量
聚合量:相对于标量那些还能够分解的数据叫作聚合量,Java中的对象就是聚合量,由于他能够分解成其余聚合量和标量 (如String为 char[] 数组和int hash)
应用: 在JIT阶段,若是通过逃逸分析,发现一个对象不会被外界访问,那么通过JIT优化,就会吧这个对象拆分红若干个其中包含的若干个成员变量来代替(当程序真正执行时不用建立这个对象,而是直接建立他的成员变量来代替,拆分后,能够分配对象的成员变量在栈或寄存器上,则本来的对象就无需分配内存空间了 ),这个过程就是标量替换public static void main(String[] args) {
Point point = new Point(1,2);
System.out.println("point.x=" +point.x+"; point.y=" +point.y);
}
class Point{
public int x;
public int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
//Point对象会被替换成两个int型
public static void main(String[] args) {
x = 1;
y = 2;
System.out.println("point.x=" + x +"; point.y=" + y);
}
复制代码
逃逸分析测试代码public class HelloTest {
public static void alloc () {
byte[] b = new byte[2];
b[0] = 1;
}
public static void main(String[] args) {
long b = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
long e = System.currentTimeMillis();
System.out.println(e - b);
}
}
}
复制代码
使用下方命令配置JVM(上面有如何在IDEA中配置,自己默认开启了,可关闭查看数据)//C1编译器参数 -client C2编译器 -server
//开/关 逃逸分析(JDK 6u23以上) 开/关锁消除 开/关标量替换 打印GC日志
//-XX:+DoEscapeAnalysis -XX:+EliminateLocks -XX:+EliminateAllocations -XX:+PrintGCDetails -Xmx10m -Xms10m
//-XX:-DoEscapeAnalysis -XX:-EliminateLocks -XX:-EliminateAllocations -XX:+PrintGCDetails -Xmx10m -Xms10m
//开启标量替换结果
[GC (Allocation Failure) [PSYoungGen: 2048K->488K(2560K)] 2048K->672K(9728K), 0.0014005 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 2536K->488K(2560K)] 2720K->712K(9728K), 0.0007950 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 2536K->504K(2560K)] 2760K->736K(9728K), 0.0015657 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
10
//关闭标量替换结果
无数次GC,运行时长 1873毫秒
复制代码
总结: 栈上的空间通常而言是很是小的,只能存放若干变化和小的数据结构,大容量的存储结构是作不到。这里的例子是一个极端的千万次级的循环,突出了经过逃逸分析,让其直接从栈上分配,从而极大下降了GC的次数,提高了程序总体的执行效能。因此,逃逸分析的效果只能在特定场景下,知足高频和高数量的容量比较小的变量分配结构,才能够生效!