Java 在运行时会将内存划分为若干个区域,粗略的可以将内存划分为堆区和栈区,堆区主要存储 Java 对象。栈区主要记录对象的引用地址。
其实还有更详细的划分。如下图所示:
Java 虚拟机栈就是我们之前将内存分为堆区和栈区,这里的栈区就是指的 Java 虚拟机栈。
每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链表、方法出口信息等。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。主要用来存储局部变量表。
在这个区域会抛出两个异常,如果请求的栈的深度超过了虚拟机允许的最大深度,会抛出 StackOverflowError ,如果无法申请到足够的内存会抛出 OutOfMemoryError 异常。
与 Java 虚拟机栈类似,本地方法栈是服务于 Native 方法的。为本地方法提供内存,同样也会抛出 StackOverflowError、OutOfMemoryError 异常。
程序计数器可以看做当前线程所执行的字节码的行号计数器。字节码的解释器需要通过改变这个值来选取下一条需要执行的字节码指令。程序中的分支、循环,跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
方法区是用来存放 JVM 装载的 class 的类信息,包括:类的方法、静态变量、类型信息(接口/父类),我们使用反射,所需的信息就是从这里获取的。同样的,当方法区无法满足内存需要的时候会抛出 OutOfMemoryError 。
常量池是方法区的一部分,用于记录编译期生成的各种字面量和符号引用。在 Java 类中使用 final 标识的字段都会放到常量池里。常量池在运行时不是一成不变的。常见的场景是 String 类的 intern 方法。当调用该方法时,JVM 会判断常量池里是否有该对象,有的话直接返回,没有的话,需要把该字符串放入常量池然后再返回。
右边橙色部分的区域都是 Java 堆。这部分内存是 Java 虚拟机里最大的内存区域。我们之前将内存粗略的划分为堆区和栈区,其中栈区指的是 Java 虚拟机栈,而堆区说的就是 Java 堆。所有的 Java 对象都是在这个区域分配的内存,虚拟机的 GC 动作 大部分都是在这个区域进行的。
再细分一下可以将 Java 堆分为新生代和老年代。新生代又可以分为 From Survivor 区、To Survivor 区、Eden 区。
新生代的 Eden 区、From Survivor 区、To Survivor 区的比值是 8:1:1。
对象在第一次创建的时候会在新生代分配内存。准确的说是在 Eden 区分配内存。 经过一次 GC 后,没有被回收的对象年龄加 1 并且会从 Eden 区、From Survivor 区转移到 To Survivor 区。 To Survivor 区会成为新的 From Survivor 区。 同样 From Survivor 区会成为新的 To Survivor 区。
当年龄超过一定的值后(默认15)会将该对象转移到老年代。
PS:并不是所有对象在第一次创建的时候都会在新生代分配内存,有些对象大到超过新生代的内存时,会直接在老年代分配内存
当 JVM 通过程序计数器的行号读取到一条 new 指令的时候,首先会去方法区寻找这个类是否被加载、解析、初始化过,如果没有的话需要先进行类的加载。
类被加载之后需要在 Java 堆中分配内存,如果 Java 堆中的内存是非常规整的,记录着使用和未使用区域的分界线,那么内存分配仅仅是将分界线向没有使用的内存方向移动一段距离就可以了。这种方式称为”指针碰撞”。如果内存并不是规整的,那么虚拟机就需要单独维护一个列表,用来记录哪些内存是可用的。在分配的时候通过这个列表找到足够大的一块内存分配给该对象。这种方式称为”空闲列表”。
这里还需要注意一个并发的问题。如果 A 对象正在分配内存,指针位置还没有来得及修改,B 对象又从原来的位置开始分配内存。
为了解决这个问题,有两种方案。一种是将内存分配进行同步处理。保证内存分配的原子性。另外一种解决方式是根据线程的不同,将分配内存的动作划分到不同的区域。每个线程都在自己的内存区域申请内存。这个区域称为本地线程分配缓冲(TLAB)。当这部分区域的内存不够时再进行同步申请新的区域。
内存分配完成后,虚拟机将内存初始化为 0 值,一些属性的默认值都是在这一步赋值的。比如 int 类型、boolean 类型的默认值等。
然后需要对对象进行必要的设置。比如说对象的年龄、对象的哈希值、对象的类的信息等。这部分数据保存在对象头中
对象创建结束后,有两种方式来访问我们创建的对象。使用句柄和直接指针。
如果使用句柄访问的话虚拟机会多划出一块内存用来做句柄池。Java 虚拟机栈中记录的就是对象的句柄地址。如下图所示:
使用直接指针访问时,Java 虚拟机的栈中记录的是对象在 Java 堆中的直接地址。如下图所示:
这两种方式各有优点,使用句柄访问时在对象移动的时候(GC时)只需要改变句柄的值就可以了,而 Java 栈中的引用地址可以不用改变。
直接指针方式最大的好处就是速度快,它减少了一次指针定位的时间开销。