文章目录
了解的必要性
Java 代码的运行过程如下图:
JVM是编译后的代码跟操作系统交流的最终路径,了解Java虚拟机的好处如下:
- 写出更好更健壮的代码
- 提高Java的性能,排除问题。
- 面试必问,要对知识有一定对深度。
JVM历史
Java虚拟机有若干的版本查看自己Java虚拟机对指令如下:
不同对公司对于会制造不同对JVM虚拟机,目前主流对还是Sun公司对HotSpot。
再比比如国内阿里的TaobaoJVM基于OpenJDK HotSpot VM,是国内第一个优化、定制且开源的服务器版Java虚拟机。目前已经在淘宝、天猫上线,全部替换了Oracle官方JVM版本,在性能,功能上都初步体现了它的价值。
以后JVM的发展可能大致如下(比如现在的JVM11对于内存的回收ZJC):
JVM数据区域
Java虚拟机在执行Java程序的过程中会把它锁管理的内存划分为若干哥不同的数据区域,主要有 程序计数器,虚拟机栈,本地方法区,虚拟机堆,方法区(运行时常量池),直接内存。
私有区
- 程序计数器
较小的内存空间,主要记录当前线程执行的字节码的行号指示器,各个线程之间独立存储互不影响。一个线程只有一个。
- Java栈
线程私有,生命周期跟线程,每个方法都会在执行的时候创建一个栈桢用来存储局部变量,操作数栈,动态链接,方法出口等信息,方法的执行就对应这栈桢在虚拟机栈中的入栈跟出栈,栈里面会存放各种基本数据类型以及对象引用,JDK1.8以后默认一个线程栈区是1M大小(-Xss)。
- 本地方法栈
本地方法栈保存的是native方法信息,当一个JVM创建一个线程调用native方法后,JVM不再为其在虚拟机栈中创建栈桢,JVM只是简单的通过动态链接方式直接调用native方法。
共有区
- 堆:
Java堆是用户需要重点关注的一块区域,因为涉及到内存的分配 (new关键字,反射等)与回收(回收算法,收集器等) (-Xms;-Xmx; -Xmn;-XX:NewSize;-XX:MaxNewSize)
- 方法区:也叫永久区,
用于存储已经被虚拟机加载的类信息,常量 (“zdy”,"123"等),静态变量(static变量)等数据(-XX:PermSize;- XX:MaxPermSize;-XX:MetaspaceSize; - XX:MaxMetaspaceSize ) 。
- 运行时常量池:
运行时常量池是方法区的一部分,用于存放编译期生成 的各种字面量(“zdy”,"123"等)和符号引用。
内存区域版本变化
1.6
1.7
1.8
在Java中用方法区(永久代)来存储类信息,常量,静态变量等数据不是好注意,因为这样很容易造成内存溢出。同时对永久代的性能调优也很困难,因此1.8以后引入了元空间来存储这些本来在方法区的东西,主要优势就是不在JVM机中占有空间了。
元空间
元空间用的是本地内存:
直接内存:不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。这块内存不受Java堆空间大小的限制,但是收本机总内存大小限制,可以通过MaxDirectMemorySize设置(默认跟堆内存最大值一样),所以也会出现OOM异常。
如果使用了NIO,本地内存区域会被频繁的使用,在Java堆内可以用directByteBuffer对象来直接应用和操作。
栈跟堆
栈(stack):
以栈桢的方式存储方法调用过程,并且存储调用过程中的基本数据类型变量或者对象的引用,其内存分配在栈上,变量出了作用域就会自动释放。
堆(heap):
堆内存中用来存储Java中我们new出来的对象,无论是成员变量,局部变量,还是类变量,他们指向的对象都是存储在堆内存中的,我们日常也经常要跟堆打交道。
独享跟共享:
== 栈内存==归属于单个线程,每一个线程都要有一个栈内存,其存储的变量只能在其所属的线程内可见,是私有的。
堆内存中的对象对所有线程可见,堆内存中的对象可以被所有线程访问。
空间大小:
栈内存要远远小区堆内存,栈的深度有限制的,因此递归调用要慎用,可能引发StackOverFlowError问题。
堆区栈区示意图:
public class SimpleHeap {
private int id;
public SimpleHeap(int id) {
super();
this.id = id;
}
public void print() {
System.out.println("My id is "+id);
}
public static void main(String[] args) {
SimpleHeap s1 = new SimpleHeap(1);
SimpleHeap s2 = new SimpleHeap(2);
s1.print();
s2.print();
}
}
方法的出入栈:
调用的方法会被打包成栈桢,一个栈桢至少需要包含一个局部变量表,操作数栈和桢数据区。
Java中的对象都是在堆中分配吗
结论:不一定看对象经过了逃逸分析跟标亮替换后发现该变量只是用到方法区呢则JVM会自动优化,在栈上创建该对象。
逃逸分析
逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存
逃逸分析的 JVM 参数如下:
开启逃逸分析:-XX:+DoEscapeAnalysis
关闭逃逸分析:-XX:-DoEscapeAnalysis(默认打开)
显示分析结果:-XX:+PrintEscapeAnalysis的一项技术。
标量替换
1.标量和聚合量
标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。
2.替换过程
通过确定该对逃逸分析象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。
-XX:+EliminateAllocations可以开启标量替换(默认打开)
-XX:+PrintEliminateAllocations查看标量替换情况(Server VM 非Product版本支持)
栈上分配
我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。那就通过将该对象标量替换分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
我们知道这点后,在平时开发过程中就要可尽可能的控制变量的作用范围了,变量范围越小越好,让虚拟机尽可能有优化的空间。
demo1:
return sowhat;
可以改为:
return sb.toString();
这是一种优化案例,把 StringBuilder 变量控制在了当前方法之内,没有逃出当前方法作用域。
demo2: 运行时候命令行参数如下:
-server -Xmx10m -Xms10m -Xms10m -XX:+PrintGC -XX:+DoEscapeAnalysis -XX:+EliminateAllocations
-server JVM运行的模式之一, server模式才能进行逃逸分析, JVM运行的模式还有mix/client
-Xmx10m和-Xms10m:堆的大小
-XX:+DoEscapeAnalysis:启用逃逸分析(默认打开)
-XX:+PrintGC:打印GC日志
-XX:+EliminateAllocations:标量替换(默认打开)
-XX:-UseTLAB 关闭本地线程分配缓冲
TLAB: ThreadLocalAllocBuffer,TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间,均摊对GC堆(eden区)里共享的分配指针做更新而带来的同步开销。
对栈上分配发生影响的参数就是三个,-server、-XX:+DoEscapeAnalysis和-XX:+EliminateAllocations,任何一个发生变化都不会发生栈上分配,因为启用逃逸分析和标量替换默认是打开的,所以,在我们的例子中,JVM的参数只用-server一样可以有栈上替换的效果(以本人机器上JDK1.8为例,其他版本JDK请自行尝试)。
public class StackAlloc {
public static class User{
public int id = 0;
public String name = "";
}
public static void alloc() {
User u = new User();
u.id = 5;
u.name = "mark";
System.out.println(u.id + u.name);
}
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)+"ms");
}
}
开启逃逸分析结果:
alloc函数最后优化结果如下
private static void alloc() {
int id = 1;
String name = 2;
System.out.println(id + name);
}
逃逸分析关闭结果:
逃逸分析结论:虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。如果对象经过层层分析后发现 无法进行逃逸分析优化则反而耗时了,因此慎用。
同步消除
同步消除是java虚拟机提供的一种优化技术。通过逃逸分析,可以确定一个对象是否会被其他线程进行访问
如果对象没有出现线程逃逸,那该对象的读写就不会存在资源的竞争,不存在资源的竞争,则可以消除对该对象的同步锁。
JVM对象
对象创建过程
前端我们一般都是通过new
来实例化对象,底层的JVM对象加载过程一般如下:
- 先执行相应的类加载过程。看类是否加载过。
- 接下来虚拟机将为新生对象分配内存。为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为指针碰撞。
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。
选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
解决这个问题有两种方案,
- 一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;
- 另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块私有内存,也就是本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),如果设置了虚拟机参数 -XX:UseTLAB,在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个Buffer,如果需要分配内存,就在自己的Buffer上分配,这样就不存在竞争的情况,可以大大提升分配效率,当Buffer容量不够的时候,再重新从Eden区域申请一块继续使用。
TLAB的目的是在为新对象分配内存空间时,让每个Java应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。
TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB。
- 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为(零值如int值为0,boolean值为false等等)。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
- 接下来,虚拟机要对对象进行必要的设置,例如这个对象是实例、哪个类的如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。
- 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始,所有的字段都还为零值。所以,一般来说,执行new指令之后会接着把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
对象内存布局
上一步创建好的对象在内存中的布局:
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
Header
- 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
- 另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
Instance Data
就是指向我们new 方法中赋值数据
Padding
对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对对象的大小必须是8字节的整数倍。当对象其他数据部分没有对齐时,就需要通过对齐填充来补全。
对象访问方式
使用对象时,通过栈上的 reference 数据来操作堆上的具体对象。
通过句柄访问
Java 堆中会分配一块内存作为句柄池。reference 存储的是句柄地址。详情见图。
目前主流的访问方式有使用句柄和直接指针两种。
- 使用句柄访问的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
- 使用直接指针访问, reference中存储的直接就是对象地址。
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。
使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。
对Sun HotSpot而言,它是使用直接指针访问方式进行对象访问的。
堆栈实战
堆溢出
前提:设置堆空间大小为5M ,-Xms5m -Xmx5m -XX:+PrintGC
。
public class OOM {
public static void main(String[] args) {
String[] strings = new String[100000000]; // 相当于直接把6M的数据放到5M的堆空间,
}
}
出现java.lang.OutOfMemoryError: Java heap space 一般是分配了巨型对象。
public class OOM {
public static void main(String[] args) {
List<Object> list = new LinkedList<>();
int i = 0;
while (true) {
i++;
if (i % 10000 == 0) System.out.println("i=" + i);
list.add(new Object()); // 对象是一点点 往上增加
}
}
}
出现java.lang.OutOfMemoryError: GC overhead limit exceeded 一般是(某个循环里可能性最大)在不停的分配对象,但是分配的太多,把堆撑爆了
栈溢出
参数:-Xss256k ,缺省默认1M
public class StackOOM {
private int stackLength = 1;
private void diGui(int x, String y) {
stackLength++;
diGui(x, y);
}
public static void main(String[] args) {
StackOOM oom = new StackOOM();
try {
oom.diGui(12, "Mark");
} catch (Throwable e) {
System.out.println("stackLength = " + oom.stackLength);
e.printStackTrace();
}
}
}
出现java.lang.OutOfMemoryError: GC overhead limit exceeded 一般是(某个循环里可能性最大)在不停的分配对象,但是分配的太多,把堆撑爆了