内存模型
下图是描述 JVM 内存模型的图:
15475447681280.PNG
JVM 包含两个子系统和两个组件, 两个子系统为 Class loader(类装载),Execution engine(执行引擎); 两个组件为 Runtime data area(运行时数据区),Native Interface(本地接口).
Class loader(类装载): 根据给定的全限定名类名 (如: java.lang.Object) 来装载 class 文件到 Runtime data area 中的 method area.
Execution engine(执行引擎): 执行 classes 中的指令.
Native Interface(本地接口): 与 native libraries 交互, 是其它编程语言交互的接口.
Runtime data area(运行时数据区): 这就是我们常说的 JVM 的内存.
程序计数器(Program Counter Register): 一块较小的内存空间, 它可以看作是当前线程所执行的字节码的行号指示器.
Java 虚拟机栈 (Java Virtual Machine Stacks): 是 Java 方法执行的内存模型: 每个方法在执行的同时都会创建一个栈帧(Stack Frame[1]) 用于存储局部变量表, 操作数栈, 动态链接, 方法出口等信息.
本地方法栈(Native Method Stack): 与虚拟机栈所发挥的作用是非常相似的, 为虚拟机使用到的 Native 方法服务.
Java 堆(Java Heap): 是 Java 虚拟机所管理的内存中最大的一块, 唯一目的就是存放对象实例, 几乎所有的对象实例都在这里分配内存.
方法区(Method Area): 用于存储已被虚拟机加载的类信息, 常量, 静态变量, 即时编译器编译后的代码等数据.
运行时常量池(Runtime Constant Pool): 是方法区的一部分, Class 文件中除了有类的版本, 字段, 方法, 接口等描述信息外, 还有一项信息是常量池(Constant Pool Table), 用于存放编译期生成的各种字面量和符号引用, 这部分内容将在类加载后进入方法区的运行时常量池中存放.
直接内存 (Direct Memory): 并不是虚拟机运行时数据区的一部分, 也不是 Java 虚拟机规范中定义的内存区域. JDK 1.4 中新加入了 NIO(New Input/Output) 类, 引入了一种基于通道 (Channel) 与缓冲区 (Buffer) 的 I/O 方式, 它可以使用 Native 函数库直接分配堆外内存, 然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作.
Heap 和 Method Area 是被所有线程的共享使用的; 而 Java stack, Program counter 和 Native method stack 是以线程为粒度的, 每个线程独自拥有自己的部分.
对象探秘
创建
下面是对象创建的主要流程:
JVM 对象创建. PNG
虚拟机遇到一条 new 指令时, 先检查常量池是否已经加载相应的类, 如果没有, 必须先执行相应的类加载. 类加载通过后, 接下来分配内存. 若 Java 堆中内存是绝对规整的, 使用 "指针碰撞" 方式分配内存; 如果不是规整的, 就从空闲列表中分配, 叫做 "空闲列表" 方式. 划分内存时还需要考虑一个问题 - 并发, 也有两种方式: CAS 同步处理, 或者本地线程分配缓冲(Thread Local Allocation Buffer, TLAB). 然后内存空间初始化操作, 接着是做一些必要的对象设置(元信息, 哈希吗...), 最后执行 < init > 方法.
内存布局
一个 Java 对象在内存中包括对象头, 实例数据和补齐填充 3 个部分:
对象的内存布局. PNG
对象头:
Mark Word: 包含一系列的标记位, 比如轻量级锁的标记位, 偏向锁标记位等等. 在 32 位系统占 4 字节, 在 64 位系统中占 8 字节;
Class Pointer: 用来指向对象对应的 Class 对象 (其对应的元数据对象) 的内存地址. 在 32 位系统占 4 字节, 在 64 位系统中占 8 字节;
Length: 如果是数组对象, 还有一个保存数组长度的空间, 占 4 个字节;
对象实际数据:
对象实际数据包括了对象的所有成员变量, 其大小由各个成员变量的大小决定, 比如: byte 和 boolean 是 1 个字节, short 和 char 是 2 个字节, int 和 float 是 4 个字节, long 和 double 是 8 个字节, reference 是 4 个字节(64 位系统中是 8 个字节).
对齐填充:
Java 对象占用空间是 8 字节对齐的, 即所有 Java 对象占用 bytes 数必须是 8 的倍数. 例如, 一个包含两个属性的对象: int 和 byte, 这个对象需要占用 8+4+1=13 个字节, 这时就需要加上大小为 3 字节的 padding 进行 8 字节对齐, 最终占用大小为 16 个字节.
访问定位
目前主流的访问方式有通过句柄和直接指针两种方式.
访问定位. PNG
这两种访问方式各有利弊, 使用句柄访最大的好处是 reference 中存储着稳定的句柄地址, 当对象移动之后(垃圾收集时移动对象是非常普遍的行为), 只需要改变句柄中的对象实例地址即可, reference 不用修改.
使用指针访问的好处是访问速度快, 它减少了一次指针定位的时间开销, 由于 java 是面向对象的语言, 在开发中 java 对象的访问非常的频繁, 因此这类开销积少成多也是非常可观的, 反之则提升访问速度. 对于 HotSpot 虚拟机来说, 使用的就是直接指针访问的方式.
实战: OutOfMemoryError 异常
Java 堆溢出
Java 堆用来存储对象, 因此只要不断创建对象, 并保证 GC Roots 到对象之间有可达路径来避免垃圾回收机制清楚这些对象, 那么当对象数量达到最大堆容量时就会产生 OOM.
- /**
- * java 堆内存溢出测试
- * VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
- */
- public class HeapOOM {
- static class OOMObject{}
- public static void main(String[] args) {
- List<OOMObject> list = new ArrayList<OOMObject>();
- while (true) {
- list.add(new OOMObject());
- }
- }
- }
运行结果:
- java.lang.OutOfMemoryError: Java heap space
- Dumping heap to java_pid7164.hprof ...
- Heap dump file created [27880921 bytes in 0.193 secs]
- Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
- at java.util.Arrays.copyOf(Arrays.java:2245)
- at java.util.Arrays.copyOf(Arrays.java:2219)
- at java.util.ArrayList.grow(ArrayList.java:242)
- at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:216)
- at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:208)
- at java.util.ArrayList.add(ArrayList.java:440)
- at com.jvm.oom.HeapOOM.main(HeapOOM.java:17)
堆内存 OOM 是经常会出现的问题, 异常信息会进一步提示 Java heap space
虚拟机栈和本地方法栈溢出
在 HotSpot 虚拟机中不区分虚拟机栈和本地方法栈, 栈容量只由 -Xss 参数设定. 关于虚拟机栈和本地方法栈, 在 Java 虚拟机规范中描述了两种异常:
如果线程请求的栈深度大于虚拟机所允许的最大深度, 将抛出 StackOverflowError 异常.
如果虚拟机在扩展栈时无法申请到足够的内存空间, 则抛出 OutOfMemoryError 异常.
- /**
- * 虚拟机栈和本地方法栈内存溢出测试, 抛出 Stack Overflow exception
- * VM ARGS: -Xss128k 减少栈内存容量
- */
- 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 = 11420
- Exception in thread "main" java.lang.StackOverflowError
- at com.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:12)
- at com.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
- at com.jvm.oom.JavaVMStackSOF.stackLeak(JavaVMStackSOF.java:13)
以上代码在单线程环境下, 无论是由于栈帧太大还是虚拟机栈容量太小, 当内存无法分配时, 抛出的都是 StackOverflowError 异常.
如果测试环境是多线程环境, 通过不断建立线程的方式可以产生内存溢出异常, 代码如下所示. 但是这样产生的 OOM 与栈空间是否足够大不存在任何联系, 在这种情况下, 为每个线程的栈分配的内存足够大, 反而越容易产生 OOM 异常. 这点不难理解, 每个线程分配到的栈容量越大, 可以建立的线程数就变少, 建立多线程时就越容易把剩下的内存耗尽. 这点在开发多线程的应用时要特别注意. 如果建立过多线程导致内存溢出, 在不能减少线程数或更换 64 位虚拟机的情况下, 只能通过减少最大堆和减少栈容量来换取更多的线程.
- /**
- * JVM 虚拟机栈内存溢出测试, 注意在 Windows 平台运行时可能会导致操作系统假死
- * VM Args: -Xss2M -XX:+HeapDumpOnOutOfMemoryError
- */
- public class JVMStackOOM {
- private void dontStop() {
- while (true) {}
- }
- public void stackLeakByThread() {
- while (true) {
- Thread thread = new Thread(new Runnable() {
- @Override
- public void run() {
- dontStop();
- }
- });
- thread.start();
- }
- }
- public static void main(String[] args) {
- JVMStackOOM oom = new JVMStackOOM();
- oom.stackLeakByThread();
- }
- }
方法区和运行时常量池溢出
方法区用于存放 Class 的相关信息, 对这个区域的测试, 基本思路是运行时产生大量的类去填满方法区, 直到溢出. 使用 CGLib 实现.
方法区溢出也是一种常见的内存溢出异常, 在经常生成大量 Class 的应用中, 需要特别注意类的回收情况, 这类场景除了使用了 CGLib 字节码增强和动态语言外, 常见的还有 JSP 文件的应用(JSP 第一次运行时要编译为 Java 类), 基于 OSGI 的应用等.
- /**
- * 测试 JVM 方法区内存溢出
- * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
- */
- public class MethodAreaOOM {
- public static void main(String[] args) {
- while (true) {
- Enhancer enhancer = new Enhancer();
- enhancer.setSuperclass(OOMObject.class);
- enhancer.setUseCache(false);
- enhancer.setCallback(new MethodInterceptor() {
- @Override
- public Object intercept(Object obj, Method method, Object[] args,
- MethodProxy proxy) throws Throwable {
- return proxy.invokeSuper(obj, args);
- }
- });
- enhancer.create();
- }
- }
- static class OOMObject{}
- }
本机直接内存溢出
DirectMemory 容量可通过 -XX:MaxDirectMemorySize 指定, 如不指定, 则默认与 Java 堆最大值一样. 测试代码使用了 Unsafe 实例进行内存分配.
由 DirectMemory 导致的内存溢出, 一个明显的特征是在 Heap Dump 文件中不会看见明显的异常, 如果发现 OOM 之后 Dump 文件很小, 而程序直接或间接使用了 NIO, 那就可以考虑检查一下是不是这方面的原因.
- /**
- * 测试本地直接内存溢出
- * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
- * @author linli.cro
- */
- public class DirectMemoryOOM {
- private static final int _1MB = 1024 * 1024;
- public static void main(String[] args) throws Exception {
- Field unsafeField = Unsafe.class.getDeclaredFields()[0];
- unsafeField.setAccessible(true);
- Unsafe unsafe = (Unsafe) unsafeField.get(null);
- while (true) {
- unsafe.allocateMemory(_1MB);
- }
- }
- }
参考
《深入理解 Java 虚拟机: JVM 高级特性与最佳实践》
来源: http://www.jianshu.com/p/8be618603c5a