JVM的内存模型

Java虚拟机在执行Java程序的过程中会把它管理的内存划分为若干个不同的数据区域, 这些区域有各自的用途, 以及创建和销毁的时间. 有的区域随着虚拟机进程的启动而一直存在, 有些区域则是依赖用户线程的启动和结束而建立和销毁

JVM Memory

JVM的内存模型分为五个部分外加一个直接内存

  • 程序计数器
  • Java虚拟机栈
  • 本地方法栈
  • 方法区(包括运行时常量池)
  • 直接内存

程序计数器

程序计数器是一小块地址, 存储着线程正在执行虚拟机字节码指令的地址. 如果执行的是本地方法, 则地址为null. 线程私有, 是唯一一个不存在OOM问题的内存地址

Java虚拟机栈

线程私有, 当调用Java方法时, 会创建相应的栈帧, 入栈. 方法执行完后, 出栈


Frame

栈帧里面包含着方法运行时所需要用到的信息, 包括局部变量表, 操作数栈, 常量池引用等.它存在两种异常: 栈溢出 StackoverFlowError 和 内存溢出 OutOfMemoryError

栈溢出在于当栈递归调用时, 超出了我们所设置的最大栈深度, 但可能内存还有很多. 对于内存溢出, 是当申请栈时发现栈已经满了, 尝试申请内存是报错

本地方法栈

类似Java虚拟机栈, 也会有栈帧的概念. 只不过Java虚拟机栈是服务于Java, 而本地方法栈可以使用其他任意语言, 只要被编译为基于本地硬件和操作系统的程序.

所有的对象(类实例, 数组)都分配在这里, 是垃圾收集的主要区域, 我们也称堆为GC堆. 它被所有的线程所共享.

关于堆的细节, 我们下文会提到, 这里先简答概括. 对于堆, 存在OOM异常

方法区

用于存放已被加载类的信息、常量、静态变量、即时编译器编译后的代码等数据

提到方法去, 经常会说到永久区. 这两个的关系在于, 在《Java虚拟规范》只是规定了有方法区这个概念和它的作用, 并没有规定如何去实现它, 所以在不同的地方实现方式不同. 在HotSpot上使用了永久区来实现方法区, 只是一种实现方式而已.

在内存上永久区是挨着堆的(在逻辑上是分开的), 为了垃圾方便回收, HotSpot在永久代上一直是使用老年代的垃圾回收算法. 主要回收目标是 常量池 和 类卸载

在JDK 1.8之后, 永久区被废弃了, 转而使用元空间, MetaSpace. 同样, 也存在OOM异常

运行时常量池

运行时常量池是方法区的一部分, 用于存放编译期间生成的各种字面量与符号引用(Class文件中的常量池), 这部分内容将在类加载后存放到方法区的运行时常量池中

除了编译期间, 还运行动态运行期间生成, 如String的intern方法

直接内存

直接内存是除Java虚拟机之外的内存, 但也经常被java所使用

例如, 在NIO中引入一种通道和缓存的I/O模式, 它可以通过调用本地方法直接分配Java虚拟机之外的内存, 然后通过存在Java堆中的 DirectoryBuffer进行直接引用, 而无需拷贝, 提升数据操作的效率

Java对象的创建奥秘

在了解了JVM运行时内存结构, 我们来看下, 一个Java对象(这里只是介绍普通对象, 不包括类对象, 数组)是如何被创建的

在此之前, 推荐阅读下 R大的 JVM里的符号引用如何存储?. 了解符号引用和直接引用的区别和过程

当虚拟机遇到一条new指令时. 会进行一系列对象创建的操作:

  • 检查常量池中是否有即将要创建这个对象所属的类的符号引用
    • 如果不存在该符号引用, 表明这个类还没有被定义. 抛出ClassNotFoundException
  • 判断这个符号引用代表类是否已经被JVM加载过了
    • 若该类还没有被加载, 就找该类的class文件, 并加载进方法区
    • 若该类已经被加载, 准备为对象分配内存
  • 根据方法区中该类的信息确定该类内存所需大小
    一个对象所需的内存大小是在这个对象所属类被定义完后就能确定的, 且每一个该类的对象所属大小的是一致的
  • 从堆中划分出相应的大小空间分配给该对象, 分配有两种不同的方式, 基于使用的GC算法
    • Bump The Point, 指针碰撞 如果GC使用的是标记-整理 or 标记-复制, 那么堆中空闲内存是完整区域, 并且空间和非空间之间通过一个指针来来标记, 分配空间仅需移动该指针就好了
    • Free List. 如果使用的是标记-清除算法, 会导致内存不是规整的, 使用的和未使用的相互交错, 我们需要通过一个列表, 来记录可用的内存, 通过遍历链表来分配空间
  • 附加问题, 假设我们使用的是指针碰撞, 可是在多线程并发下, 如何保证指针移动的安全. 比如线程A给对象C划分了一块地址, 准备分配时, 线程切换.
    • CAS, 通过乐观锁, 来保证分配的安全
    • TLAB(Thread Local Allocate Buffer), 即在堆中留出一部分地方, 给每个线程一人一块, 这样你分配你的, 我分配我的, 互不影响. 注意, 只是在分配的时候互不影响, 引用的时候还是共享的
  • 为对象中的成员变量赋上初始值(默认初始化)
  • 设置对象头中的信息
  • 调用对象的构造函数进行初始化

对象的创建围绕着堆和方法区, 后续我们在JVM-垃圾收集中会着重描述这两块内存地址

了解运行时内存结构后, OOM是一个常见的面试问题

Linux下的OOM

OOM, 全称为Out Of Memory. 当JVM因为没有足够的内存来为对象分配空间并且垃圾回收器也已经没有可用空间回收时, 就会抛出这个错误

  1. java.lang.OutOfMemoryError:Java heap space
    Java应用程序在启动时会指定所需要的内存大小, 它被分割称成两个不同的区域: heap spacePermgen(永久代)
    Heap Space

当应用程序试图想堆空间添加更多的数据时, 但堆却没有足够的内存来容纳这些数据, 将会触发该异常. Ps: 即便物理空间还有足够的内存, 但因为JVM的堆内存的设置达到了大小限制

class OOM {
        static final int SIZE=2*1024*1024;
        public static void main(String[] a){
                int[] i = new int[SIZE];
        }

}

# javac OOM.java
# java -Xmx12m OOM
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
    at OOM.main(OOM.java:4)
# java -Xmx13m OOM
#

除此之外, 内存泄露也是会导致应用程序不停的消耗更多的内存, 随着时间的推移, 泄露的对象会消耗所有的堆空间, 触发OOM
在Java中因为具有垃圾自动回收的机制, 导致内存泄漏不是那么明显, 但是仍然存在某些操作, 使得GC无法识别一些已经不再使用的对象, 而这些未使用的对象一直留在堆空间中

  1. java.lang.OutOfMemoryError: GC overhead limit exceeded
    Java有垃圾收集器, 默认情况下, 当应用程序花费超过98%的收集收集不到2%的可用空间时, 就会抛出该错误. 因为GC收集器收集垃圾时, 大部分的收集器都会停止应用进程的工作, 详情可了解Stop The World. 所以在此情况, 我们已经明知应用几乎已经耗尽了内存, 而且也不存在太多的可用空间, 为了避免CPU资源的消耗, 会进行报错
import java.util.HashMap;
import java.util.Map;

class MemoryLeak {
        static class Key {
                Integer id;

                Key(Integer id){
                        this.id = id;
                }

                public int hashCode(){
                        return id.hashCode();
                }
        }

        public static void main(String[] args){
                Map<Key, String> map = new HashMap<>();
                while(true){
                        for(int i = 0; i < 10000; i++){
                                if(!map.containsKey(new Key(i))){
                                        map.put(new Key(i), "Number:" + i);
                                }
                        }
                }
        }
}

# javac MemoryLeak.java                                                                                                                                                                                                     #  ~/test  java -Xmx12m MemoryLeak                                                                                                                                                                                                        
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
    at java.util.HashMap.replacementTreeNode(HashMap.java:1749)
    at java.util.HashMap.treeifyBin(HashMap.java:761)
    at java.util.HashMap.putVal(HashMap.java:643)
    at java.util.HashMap.put(HashMap.java:611)
    at MemoryLeak.main(MemoryLeak.java:22)
  1. 本地内存不足以支持加载Java类(MetaSpace or Perm)

  2. Out of memory: Kill process or sacrifice child
    在Linux中, 有一个非常特殊的杀手进程, 叫做内存杀手, Out Of Memory Kill. 当内核检测到系统内存不足时, oom killer 会被激活, 然后选择一个进程被杀掉(会有一个算法, 计算每个进程的使用内存得分, oom killer会选择分数最高的进程杀掉以释放内存模型)

当可用虚拟内存(包括交换空间)消耗到让整个操作系统面临风险时, oom killer就会出动.其原理在于:
默认情况下,Linux内核允许进程请求比系统可用内存更多的内存, OverCommit 策略, 因为大多数进程实际上并没有使用完他们所分配的内存. 可真的当大多数进程都快消耗完内存分配的内存, 当再有进程请求分配更多的内存时, 便超过了内存的上限.

可以通过 dmesg -T | grep java 或者 /var/log/messages

如果你想了解更多, 请参考
Java内存溢出(OOM)异常完全指南
理解和配置 Linux 下的 OOM Killer
Linux应用进程消失之谜--Java进程与OOM Killer

方法区

由于我们经常使用的是Oracle 的JVM, 所以提到方法区, 我们一般都称作为永久代

Java 7

在Java 7及以前, 方法区和堆在物理上是连续的, 只是逻辑上分开.


image.png

永久代用于存储已被虚拟机加载的类信息、常量、静态变量、即使编译器编译后的代码缓存等数据. 从Java8开始, HotSpot取消了永久代, 引进了一个新的东西 MetaSpace, 元空间

元空间

Meta Sapce

元空间不再和堆连续, 而是存在于本地内存(Native Memory). 存在本地空间, 也就意味着不再需要指定永久代的大小, 只要本地空间足够, 就不会出现OOM的问题.

iJava 8

为什么永久代要被替换?

  1. PermGen的大小很难调整, 因为应该为PermGen分配多大的空间很难确定, 它的大小依赖很多因素, 比如JVM加载的class总数, 常量池的大小, 方法的大小
  2. 便于和JRockit代码的合并

除此之外, 前面提到的运行时常量池数据也移动到了别的地方.

  • 符号引用 转移到 本地内存
  • 字面量 转移到 Java堆
  • 类的静态变量 转移到 Java堆

总结

到这里, 我们很完整的介绍了 Java运行的内存结构, 其中包括线程私有和线程共享的. 里面有操作系统分配的Java内存, 也有直接使用的本地内存

Java中最重要的就是堆 和 方法区. 了解它们, 有利于你明白类加载机制, 对象创建, 数组创建等. 在Java 7前GC垃圾收集器是统一对堆和方法区进行回收, Java 8之后, 元空间拥有自己独立的回收方式

关于内存, 就不可避免的提到OOM. 对于OOM, 我们要关注两个方面: Java的OOM 和 Linux的OOM

附加阅读: 延伸于一个问题, Java进程跟正常进程有什么不同, 尤其在内存上,为什么布局不一样?
参考: JVM 与 Linux 的内存关系详解

©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。