美文网首页
Java内存区域与内存溢出异常——《深入理解JVM》读书笔记

Java内存区域与内存溢出异常——《深入理解JVM》读书笔记

作者: HaoR_W | 来源:发表于2018-03-18 05:55 被阅读0次

    一、运行时的数据区

    JVM运行时数据区

    1. 线程隔离区域

    (1) 程序计数器(Program Counter Register)

    较小的内存空间,可看作是当前线程所执行的字节码的行号指示器。其值用来取值。

    • 每个线程都需要一个独立的程序计数器从而保证线程切换后可以恢复到正确的执行位置。
    • 如果线程正在执行Java方法,计数器记录虚拟机字节码的指令地址;如果正在执行Native方法,计数器值为Undefined。
    • 唯一没有规定任何OOM Error的区域

    (2) Java虚拟机栈(Java Virtual Machine Stacks)

    为VM执行Java方法(字节码)服务,是Java方法执行的内存模型。

    • 每个方法在执行的同时会创建一个栈帧(Stack Frame)用于存储局部变量表操作数栈动态链接方法出口等信息。
    • 每一个方法从调用直至执行完成的过程,就对应着一个栈帧在VM中从入栈到出栈的过程。

    局部变量表

    虚拟机栈中的局部表量表即是通常所说的栈。

    • 所需的内存空间在编译期间完成分配,方法运行期间不会改变局部变量表的大小
    • 存放编译期可知的基本数据类型(boolean,byte,char,short,int,float,long,double)、对象引用(reference类型)和returnAddress类型(指向一条字节码指令的地址)。
    • 其中64bit的longdouble类型的数据会占用2个局部变量空间(Slot),其余的只占1个。
    • 注意并不意味着基本数据类型变量(Primitive Variables)都在Stack上(只有其中Local的在),比如Object中的成员变量就在Java Heap上[1]

    虚拟机栈的两种错误

    StackOverFlowError:线程请求的栈深度大于VM允许的深度(通常见到的是这种)。
    OutOfMemoryError:如果虚拟机栈可以动态扩展,若扩展时无法申请到足够的内存会抛出该Error。

    (3) 本地方法栈(Native Method Stack)

    为VM使用到的Native方法服务。HotSpot虚拟机中直接把本地方法栈和虚拟机栈合二为一。

    • 大体与虚拟机栈类似。
    • 会抛出StackOverflowErrorOutOfMemoryError

    2. 所有线程共享区域

    (1) Java堆(Java Heap)

    存放几乎所有的对象实例。是GC管理的重要区域,也称为“GC堆”。

    • JVM规范中的描述是Java堆包含数组(Arrays)对象实例(Class Instances)。不过随着技术的发展这也不一定了。
    • 可处于物理上不连续的内存空间,保证逻辑上连续即可。
    • 主流VM中都按可扩展容量实现(通过-Xmx-Xms控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,会抛出OutOfMemoryError

    (2) 方法区(Method Area)

    用于存储已被虚拟机加载的类信息常量静态变量即时编译器编译后的代码等数据。

    • 别名Non-Heap,目的是与Java堆区分开来。
    • 不需要物理连续;可以选择可扩展或固定大小。
    • 该区域GC一般比较少出现,但并不是不需要,否则可能会导致内存泄漏。
    • 会抛出OutOfMemoryError

    运行时常量池(Runtime Constant Pool)

    是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一个常量池(Constant Pool Table)用于存放编译期生成的各种字面量和符号引用(Symbolic Reference),这部分内容将在类加载后进入方法区的运行时常量池中存放。

    简言之,用于存放Class文件中的常量池中的内容。

    • 除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存在其中。
    • 具备动态性,非预置入Class文件常量池的新常量也可能在运行期间放入池中(利用的比较多的是Stringintern():若不存在常量池中则将其放入)[2]
    • 可能导致OutOfMemoryError

    3. 直接内存(Directed Memory)

    不是运行时数据区的一部分,也不是规范中定义的内存区域。不过也会导致OutOfMemoryError

    JDK1.4中加入的NIO(New Input/Output)类引入一种基于Channel和Buffer的I/O方式,可以使用Native函数库直接分配堆外内存,然后通过一个存在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,在一些场景可以提升性能,因为避免了在Java堆和Native堆中来回复制数据

    不受Java堆大小的限制,受本机总内存和处理器寻址空间的限制。

    可能出现OOM的情况:设置的-Xmx等各个内存区域总和大于物理内存限制,导致动态扩展中出现OOM Error。

    二、创建和访问对象

    1. 创建对象

    虚拟机遇到new指令时,先去检查对应参数是否能在常量池中定位到一个类的符号引用,并检查该符号引用代表的类是否已被加载、解析和初始化。若没有则必须先执行相应的类加载过程(该过程中对象的大小即可确定)。类加载检查通过后即为新生对象分配内存

    指针碰撞(Bump the Pointer)

    若Java堆中的内存是绝对规整的,用过的放一边,空闲的放另一边,中间放一个指针作为分界点,则分配空间时将指针移动一段与对象大小相等的距离。

    通常用在使用带Compact过程的垃圾收集器的VM中。

    空闲列表(Free List)

    Java堆中内存不是规整的,维护一个list来记录哪些内存块是可用的。

    通常用在使用基于Mark-Sweep算法的收集器的VM中。

    并发安全

    对象创建非常频繁,多线程并发时不一定安全。

    有两种解决方法:
    (1)对分配内存空间的动作进行同步处理——CAS加失败重试的方式保证更新操作的原子性;
    (2)本地线程分配缓冲(Thread Local Allocation Buffer, TLAB,-XX:+/-UseTLAB),每个线程在Java堆中预先分配的一小块内存。只用在TLAB用完并分配新的TLAB时才需要同步锁定。

    内存分配完毕后

    VM将分配到的内存空间都初始化为零值。若使用TLAB,该过程也可在分配TLAB时进行。

    将必要的设置信息(类信息、HashCode、GC分代年龄信息等)存放在该对象的对象头(Object Header)中。

    到此为止,在VM看来一个对象已经创建完成(但init方法还没执行,一般在new指令之后接着执行)。

    2. 访问对象

    主流的有使用句柄和使用直接指针两种。

    句柄

    Java堆中一块内存用作句柄池,reference存储对象的句柄地址,句柄中包含对象实例数据与类型数据各自的具体地址信息。

    通过句柄访问对象

    优势:reference中存储的时稳定的句柄地址,对象被移动时只改变句柄中的实例数据指针,而reference本身不需要修改。

    直接指针

    Java堆中对象的布局中必须考虑如何放置访问类型数据相关信息,reference中直接存储对象地址。是Sun HotSpot使用的方式。

    通过直接指针访问对象

    优势:速度更快,因为节省了一次指针定位的时间开销。

    三、OOM错误

    1. Java堆溢出

    实验

    不停创造对象并保证GC Roots到对象之间有可达的路径

    JVM参数:
    // 设置Java堆大小为20m,且不可拓展
    -Xms20m // 限制Java堆的最小大小是20m
    -Xmx20m // 限制Java堆的最大大小是20m
    
    // 让JVM在出现OOM Error时Dump出当前的内存堆转储快照
    -XX:+HeapDumpOnOutOfMemoryError
    
    Java代码:
    public class HeapOOM {
    
        static class OOMObject {
        }
    
        public static void main(String[] args) {
            List<OOMObject> list = new ArrayList<>();
            while (true) {
                list.add(new OOMObject());
            }
        }
    }
    

    处理思路

    出现OOM错误时注意区分是内存泄漏(Leak)还是内存溢出(Overflow)
    内存泄漏:需要回收的对象没有被回收,需要检查泄漏对象的类型GC Roots引用链的信息从而定位泄漏代码的位置。
    内存溢出:内存中的对象都还在生命期内,需要检查Java堆内存大小的设置以及代码中对象是否有生命周期过长的问题。

    2. 虚拟机栈和本地方法栈溢出

    JVM参数:
    -Xoss:设置本地方法栈大小(实际上无效)
    -Xss128k:设置栈容量128KB
    -Xss2M:设置栈容量2MB
    
    Java代码:
    /**实验一:无限递归**/
    /**该线程所需的栈空间不足**/
    /**StackOverflowError**/
    public class JavaVMStackOF {
    
        private int stackLength = 1;
    
        public void stackLeak() {
            stackLength++; // 不是线程安全的,不过此处是单线程所以没影响
            stackLeak();
        }
    
        public static void main(String[] args) throws Throwable {
            JavaVMStackOF oom = new JavaVMStackOF();
            try {
                oom.stackLeak();
            } catch (Throwable e) {
                System.out.println("stack length:" + oom.stackLength);
                throw e;
            }
        }
    }
    
    /**实验二:无限活动线程**/
    /**分配给所有线程的栈空间过大**/
    /**StackOverflowError**/
    /**Windows平台VM中Java线程直接映射到OS的内核线程上,故可能导致OS假死**/
    public class JavaVMStackOOM {
    
        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) throws Throwable {
            JavaVMStackOOM oom = new JavaVMStackOOM();
            oom.stackLeakByThread();
        }
    }
    

    3. 方法区和运行时常量池溢出

    JVM参数:
    -XX:PermSize=10M
    -XX:MaxPermSize=10M
    
    /**实验:不停往运行时常量池添加常量**/
    public class RuntimeConstantPoolOOM {
    
        public static void main(String[] args) {
            // 使用List保持常量池引用,避免Full GC回收常量池行为
            List<String> list = new ArrayList<>();
            // 10MB的PermSize在integer的范围内足够产生OOM了
            int i = 0;
            while (true) {
                list.add(String.valueOf(i++).intern());
            }
        }
    }
    

    关于String.intern()有进一步测试:

    /**String.intern()返回引用的测试**/
    public class RuntimeConstantPoolOOM {
    
        public static void main(String[] args) {
            String str1 = new StringBuilder("computer").append("software").toString();
            System.out.println(str1.intern() == str1);
    
            String str2 = new StringBuilder("ja").append("va").toString();
            System.out.println(str2.intern() == str2); // str2.intern()并不会修改str2的值
        }
    }
    

    在JDK1.6中会得到两个false,在JDK1.7中会得到一个true和一个false。因为JDK1.6中会把首次遇到的String实例复制到常量池中,返回的也是常量池中该实例的引用,与StringBuilder创建的在堆上的对象不是同一个引用;而JDK1.7开始的intern()不再复制实例,而是在常量池中记录首次出现的实例的引用并返回该引用,故指向的是同一个实例。对str2返回false是因为"java"这个字符串在执行StringBuilder.toString()之前已经出现过并存在常量池中了[3],所以返回的是之前存在的实例的引用。

    String与运行时常量池的关系还存在一些需要注意的情况[4]

    4. 本机直接内存溢出

    JVM参数:
    -XX:MaxDirectMemorySize=10M  // 设置直接内存的大小为10MB,若不指定则默认与Java堆最大值一样
    -Xmx20M
    
    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);
            }
        }
    }
    

    越过DirectByteBuffer类,通过反射获取Unsafe实例进行内存分配。因为前者分配内存时是通过计算得知无法分配后手动抛出Error,而没有真正向OS申请内存。真正申请分配内存的方法是unsafe.allocateMemory()

    DirectMemory导致的内存溢出在Heap Dump文件中不会看见明显的异常。若OOM后Dump文件很小,同时程序中直接或间接地使用了NIO,即可考虑这方面的问题。





    03/17/2018


    1. Is a Java array of primitives stored in stack or heap? -- StackOverflow

    2. 深入解析String#intern

    3. 常量池中为什么会存在"java"这样的字符串? - 知乎

    4. 几张图轻松理解String.intern()

    相关文章

      网友评论

          本文标题:Java内存区域与内存溢出异常——《深入理解JVM》读书笔记

          本文链接:https://www.haomeiwen.com/subject/roigfftx.html