虚拟机整体结构
我们先来张图看看虚拟机的整体结构
我们可以从上图看出,JVM大概可以分为以下几部分内容:类加载器、内存空间、执行引擎、本地方法接口、本地方法库。接下来我们在本篇文章中着重分析内存空间。
JVM内存空间
JVM内存空间大致分为程序计数器、堆、虚拟机栈、本地方法栈、方法区、直接内存几部分。
程序计数器
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,我们称这类内存区域为“线程私有”的内存。
此内存区域是唯一一个在Java 虚拟机规范中没有规定任何OutOfMemoryError情况的区域
虚拟机栈
线程私有,它的生命周期与线程相同。虚拟机栈描述的是Java 方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧、用于存储局部变量表、操作栈、动态链接、方法出口等信息。
每个在虚拟机中运行的程序也是由许多的帧的切换产生的结果,只是这些帧里面存放的是方法的局部变量,操作数栈,动态链接,方法返回地址和一些额外的附加信息组成。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
我们来简单看下局部变量表和动态链接:
局部变量表
一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量
动态连接
虚拟机运行的时候,运行时常量池会保存大量的符号引用,这些符号引用可以看成是每个方法的间接引用。如果代表栈帧A的方法想调用代表栈帧B的方法,那么这个虚拟机的方法调用指令就会以B方法的符号引用作为参数,但是因为符号引用并不是直接指向代表B方法的内存位置,所以在调用之前还必须要将符号引用转换为直接引用,然后通过直接引用才可以访问到真正的方法。
如果符号引用是在类加载阶段或者第一次使用的时候转化为直接应用,那么这种转换成为静态解析,如果是在运行期间转换为直接引用,那么这种转换就成为动态连接。
本地方法栈
与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native 方法服务。
与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
方法区
方法区在一个jvm实例的内部,类型信息被存储在一个称为方法区的内存逻辑区中。类型信息是由类加载器在类加载时从类文件中提取出来的。类(静态)变量也存储在方法区中。
存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,如字面量及符号引用,类的全限定名,继承的父类全限定名,实现的接口全限定名,访问修饰符,方法访问修饰符、参数列表、返回值类型 、方法名,字段访问修饰符、字段名称、字段类型,除了常量以外的所有类(静态)变量,还有一个指向ClassLoader的指针,一个指向Class对象的指针, 常量池(常量数据以及对其他类型的符号引用)等
直接内存
JDk1.4中新加入了NIO类,引入了一种基于通道与缓冲区的I/O方式,它可以使用Native函数直接分配堆外内存,这样能够在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
堆
堆是Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
堆分为Eden,Survivor to,Survivor from,Old等区域
内存分配过程
1、JVM 会试图为相关Java对象在Eden Space中初始化一块内存区域。
2、当Eden空间足够时,内存申请结束;否则到下一步。
3、JVM 试图释放在Eden中所有不活跃的对象(这属于1或更高级的垃圾回收)。释放后若Eden空间仍然不足以放入新对象,则试图将部分Eden中活跃对象放入Survivor区。
4、Survivor区被用来作为Eden及Old的中间交换区域,当Old区空间足够时,Survivor区的对象会被移到Old区,否则会被保留在Survivor区。
5、当Old区空间不够时,JVM 会在Old区进行完全的垃圾收集(0级)。
6、完全垃圾收集后,若Survivor及Old区仍然无法存放从Eden复制过来的部分对象,导致JVM无法在Eden区为新对象创建内存区域,则出现“outofmemory”错误。
对象内存布局
对象内存中布局可以分为3块:对象头,实例数据和对齐填充
对象头
对象头分为两部分:
1.运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
2.类型指针,即对象指向它的类元数据指针,虚拟机通过这个指针来确定属于哪个类的实例。
对象访问
对象访问在Java 语言中无处不在,是最普通的程序行为,但即使是最简单的访问,也会却涉及Java 栈、Java 堆、方法区这三个最重要内存区域之间的关联关系,如下面的这句代码:
Object obj = newObject();
假设这句代码出现在方法体中,那“Object obj”这部分的语义将会反映到Java 栈的局部变量表中,作为一个reference 类型数据出现。而“new Object()”这部分的语义将会反映到Java 堆中,形成一块存储了Object 类型所有实例数据值(Instance Data,对象中各个实例字段的数据)的结构化内存,根据具体类型以及虚拟机实现的对象内存布局(Object Memory Layout)的不同,这块内存的长度是不固定的。另外,在Java 堆中还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存储在方法区中。
由于reference 类型在Java 虚拟机规范里面只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到Java 堆中的对象的具体位置,因此不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有两种:使用句柄和直接指针。
句柄访问方式
Java 堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。
直接指针访问方式
reference变量中直接存储的就是对象的地址,而java堆对象一部分存储了对象实例数据,另外一部分存储了对象类型数据。
4种情况必须立即对类进行初始化
1、遇到new(使用new关键字实例化对象)、getstatic(获取一个类的静态字段,final修饰符修饰的静态字段除外)、putstatic(设置一个类的静态字段,final修饰符修饰的静态字段除外)和invokestatic(调用一个类的静态方法)这4条字节码指令时,如果类还没有初始化,则必须首先对其初始化
2、使用java.lang.reflect包中的方法对类进行反射调用时,如果类还没有初始化,则必须首先对其初始化
3、当初始化一个类时,如果其父类还没有初始化,则必须首先初始化其父类
4、当虚拟机启动时,需要指定一个主类(main方法所在的类),虚拟机会首选初始化这个主类
除了上面这4种方式,所有引用类的方式都不会触发初始化,称为被动引用。如:通过子类引用父类的静态字段,不会导致子类初始化;通过数组定义来引用类,不会触发此类的初始化;引用类的静态常量不会触发定义常量的类的初始化,因为常量在编译阶段已经被放到常量池中了。
常见异常及实例代码
内存溢出和内存泄漏
内存溢出 out of memory,是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;比如申请了一个integer,但给它存了long才能存下的数,那就是内存溢出。
内存泄露 memory leak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。
memory leak会最终会导致out ofmemory。
Java 堆内存的OutOfMemoryError异常是实际应用中最常见的内存溢出异常情况。出现Java 堆内存溢出时,异常堆栈信息“java.lang.OutOfMemoryError”会跟着进一步提示“Java heapspace”。
要解决这个区域的异常,一般的手段是首先通过内存映像分析工具(如Eclipse Memory Analyzer)对dump 出来的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。
如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots 的引用链。于是就能找到泄漏对象是通过怎样的路径与GC Roots 相关联并导致垃圾收集器无法自动回收它们的。掌握了泄漏对象的类型信息,以及GC Roots 引用链的信息,就可以比较准确地定位出泄漏代码的位置。
如果不存在泄漏,换句话说就是内存中的对象确实都还必须存活着,那就应当检查虚拟机的堆参数(-Xmx 与-Xms),与机器物理内存对比看是否还可以调大,从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减少程序运行期的内存消耗。
栈溢出
导致各个内存区域溢出异常实例代码:
STACKOVERFLOW异常
package exceptiontest;
public class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) throws Throwable {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length: " + oom.stackLength);
throw e;
}
}
}
运行结果:
stack length: 19792
Exception in thread “main” java.lang.StackOverflowError
at exceptiontest.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:9)
堆内存溢出代码
package exceptiontest;
import java.util.ArrayList;
import java.util.List;
public class HeapOOM {
static class OOMObject {}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<OOMObject>();
long i = 1;
while (true) {
list.add(new OOMObject());
}
}
}
方法区溢出
package exceptiontest;
import java.lang.reflect.Method;
public class JavaMethodAreaOOM {
public static void main(String[] args) {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(OOMObject.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy)
throws Throwable {
return proxy.invokeSuper(obj, args);
}
});
enhancer.create();
}
}
static class OOMObject {
}
}
常量池溢出
package exceptiontest;
import java.util.ArrayList;
import java.util.List;
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}
直接内存溢出
package exceptiontest;
import java.util.ArrayList;
import java.util.List;
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
int i = 0;
while (true) {
list.add(String.valueOf(i++).intern());
}
}
}
/*public String intern()
返回字符串对象的规范化表示形式。
一个初始时为空的字符串池,它由类 String 私有地维护。
当调用 intern 方法时,如果池已经包含一个等于此 String 对象的字符串(该对象由 equals(Object) 方法确定),则返回池中的字符串。否则,将此 String 对象添加到池中,并且返回此 String 对象的引用。
它遵循对于任何两个字符串 s 和 t,当且仅当 s.equals(t) 为 true 时,s.intern() == t.intern() 才为 true。
所有字面值字符串和字符串赋值常量表达式都是内部的。*/
jdk常用命令
对于一些常见的问题,我们可以使用jdk提供的一些工具来分析我们的jvm内存状况。从而跟踪和分析问题,如下是一些常用的命令。
jps 查看虚拟机进程
jstat 用于监控虚拟机各种运行状态信息的命令行工具
jinfo 实时查看和调整虚拟机各项参数
jmap 用于生成堆转储快照
jhat 虚拟机堆转储快照分析工具
jstack java堆栈跟踪工具
具体用法大家可以根据需求去查,后续我会慢慢填充这一块。
判断对象是否可回收?
- 引用计数法
类似C++智能指针,但是当两个对象互相引用的时候,可能造成无法回收的情况。在c++当中是根据实际业务情况用弱引用来强制回收分配内存。 - 可达性分析算法
算法的基本思想就是通过一系列成为”GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径成为引用链,当一个对象到GC Roots没有任何引用链的时候,则证明此对象是不可用的。
在Java中,可作为引用链的GC Roots对象包括下面几种- 虚拟机栈(本地变量表)中引用的对象。
- 方法区中静态变量引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI引用的对象。
垃圾收集算法:
标记-清除法 优缺点:速度慢,碎片多
复制法 优缺点:速度快,但有一半内存无法利用 主要用于在新生代垃圾回收
标记-整理法 优缺点:速度慢,内存利用率高 主要用于老年代垃圾回收
关于垃圾回收更多内容可参考这篇文章
引用概念:
强引用: 类似”Object obj = new Object()”,只要引用还在,垃圾收集器就无法回收
软引用:内存溢出之前,才会对这些对象进行回收。
弱引用:实例化到下次GC就会被回收
虚引用:虚引用主要用来跟踪对象被垃圾回收器回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。