Java8-JVM内存区域划分白话解读
Posted b1ackc4t
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java8-JVM内存区域划分白话解读相关的知识,希望对你有一定的参考价值。
前言
java作为一款能够自动管理内存的语言,与传统的c/c++语言相比有着自己独特的优势。虽然我们无需去管理内存,但为了防范可能发生的异常,我们需要对java内部数据如何存储有一定了解,已应对突发问题,写出更好的程序
JVM对运行时程序内存的划分
java程序在被编译成字节码后,由JVM执行,执行期间产生的所有数据,会被分门别类的存储在JVM预设好的区域里,具体情况如下所示
java6时方法区还属于JVM管理的内存,那时俗称为“永久代”,负责存储:被虚拟机加载的类型信息、方法信息、常量(包括字符串常量)、静态变量等等
java7时把永久代里的字符串常量池、静态变量移动到了堆中
java8废除永久代,改用元空间来实现方法区,原来java7中永久代的剩余内容移动到元空间中
以下为java8的内存分布图
Tips:红色是线程共享的,黄色是线程私有的
接下来我们着重讨论Java8中的内存分布情况
JVM管理的内存
这部分内存在JVM中,由JVM直接分配,初始大小、最大大小都可以由JVM进行配置
程序计数器
是一段较小的内存空间,用于告诉字节码解释器下一条执行哪一个字节码指令。是唯一一个在《java虚拟机规范》没有规定任何OutOfMemoryError的区域
每条线程必须有独立的程序计数器,以确保切换线程时,线程可以在正确的位置继续执行字节码
Tips:当执行Native方法时,计数器值为空(undefined)
虚拟机栈
我们平常俗称的栈指的就是虚拟机栈,用来描述和存储Java方法的内存模型。里面的数据生命周期在编译时就已经确定了,比如局部变量方法调用结束就该释放,内存很容易管理,所以并不是很依赖GC
具体行为:
每当执行一个方法时,JVM就会创建一个栈帧放进虚拟机栈中
栈帧的内容包括但不限于:
- 局部变量表(也包括形参)
- 八大基本数据类型
- 引用类型(直接指针或者句柄,由具体的JVM实现决定)
- returnAddress类型 (用于方法结束回到原来的字节码位置继续执行)
- 操作数栈
- 开始时是空的,运行后逐渐入栈出栈,比如算数运算就是操作数栈进行的
- 动态连接
- 方法出口
直到方法执行结束,JVM就会将此方法的栈帧出栈
显然,如果多个线程共用同一虚拟机栈,会出现某个线程的方法还没执行完毕,又被另一线程的栈帧入栈,破坏了方法数据结构。所以虚拟机栈是线程私有的
returnAddress作用
用于执行完方法后回到调用方法的位置继续往下执行
当一个栈帧入栈时,returnAddress保存当前程序计数器的值,即当前字节码位置,然后开始执行方法,方法执行结束后,用returnAddress的值恢复程序计数器,即回到调用方法时的字节码位置
本地方法栈
几乎与虚拟机栈一样的作用,其区别是,本地方法栈为本地Native方法服务,通常是本地的C/C++库的方法,而虚拟机栈是为java方法服务的
堆区
通常是JVM中最大的内存区域,也是垃圾收集器GC最经常光顾的区域,里面的数据生命周期无法在编译时确定,需要GC来帮助判断是否是“死亡变量”,以回收没必要的内存。
存储的内容:
- 对象的实例
- 数组
- 字符串常量池
- 物理上在堆区,逻辑上是方法区的内容
- 静态变量
- 物理上在堆区,逻辑上是方法区的内容
本地内存
默认情况使用大小只受限于本地内存的实际大小
但我们任可以通过JVM配置限制使用大小
这里面的数据一般不经常变动,存放在这里被JVM间接管理较为合适(间接管理速度肯定比JVM内部的慢些)
方法区(元空间)
java8使用元空间来实现的方法区,《Java虚拟机规范》中方法区为堆区的逻辑部分,堆中的对象依靠方法区存储的类信息来生成实例
存储的内容:
- 运行时常量池
- 字面量
- 符号引用
- 类信息
- 类型
- 完整名
- 修饰符
- 父类、接口信息
- 域
- 名称
- 类型
- 方法
- 名称
- 参数
- 返回值
- 字节码
- 类型
以上包含了一些有代表性的内容,并不代表方法区存储的全部内容
直接内存
此部分并不常用,至少对我目前来说。
在jdk1.4中加入了NIO(New Input/Putput)类,引入了一种基于通道(channel)与缓冲区(buffer)的新IO方式,它可以使用native函数直接分配堆外内存,然后通过存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样可以在一些场景下大大提高IO性能,避免了在java堆和native堆来回复制数据。--《深入理解java虚拟机》
Java——JVM内存详解
1. 简介
Java 程序运行时,需要在内存中分配空间。为了提高运算效率,就对空间进行了不同区域的划分,因为每一片区域都有特定的处理数据方式和内存管理方式。
分配:通过关键字new创建对象分配内存空间,对象存在堆中。
释放 :对象的释放是由垃圾回收机制决定和执行的
JVM的内存可分为3个区:
- 堆(heap)
- 栈(stack)
- 方法区(method,也叫静态区):
2. 内存区域的划分
一个java程序运行的数据区:
堆区: 程序员自己申请,运行时线程公有
- 通过new生成的对象都存放在堆中,对于堆中的对象生命周期的管理由Java虚拟机的垃圾回收机制GC进行回收和统一管理
- jvm只有一个堆区(heap),且被所有线程共享,堆中不存放基本类型和对象引用,只存放对象本身和数组本身;
- 优点是可以动态分配内存大小,缺点是由于动态分配内存导致存取速度慢。
虚拟机栈: 系统自己分配,运行时线程私有
- 栈内存主要是存放一些基本类型的变量和对象的引用变量。最典型的就是我们new一个对象时,对象名作为变量就存放在栈内存中
- 栈内存有一个很重要的特殊性——在栈中的数据可以共享(已存在的值不会再次创建)
int a = 3;
int b = 3;
(编译器先处理int a =3;首先它会在栈中创建一个变量为a的引用,然后查找栈中是否有3这个值,如果没找到,就将3存放进来,然后将a指向3。接着处理int b) - 每个方法执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等。局部变量表存放了编译器可知的各种基本数据类型、对象引用和returnAddress类型
- 每个方法从调用直至完成的过程,都对应着一个栈帧从入栈到出栈的过程。每当一个方法执行完成时,该栈帧就会弹出栈帧的元素作为这个方法的返回值,并且清除这个栈帧,Java栈的栈顶的栈帧就是当前正在执行的活动栈,也就是当前正在执行的方法,方法的调用过程也是由栈帧切换来产生结果。
- 在JVM规范中,对这个区域规定了两种异常情况:
1.如果线程请求的栈深度大于虚拟机允许的深度,将抛出StackOverflowError异常;
2.如果虚拟机栈可以动态扩展,在扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
本地方法栈:
和虚拟机栈所发挥的作用基本一致,不同的是:
- 虚拟机栈为虚拟机执行Java方法(字节码)服务
- 本地方法栈则为虚拟机使用到的Native方法服务
- 本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。
方法区(静态区):
- 是各个线程共享的内存区域,它用于存储class二进制文件,包含所有的class和static变量,包含了虚拟机加载的类信息、常量(常量池)、静态变量(静态域)、即时编译后的代码等数据。
- 方法区中包含的都是在整个程序中永远唯一的元素,如class,static变量。
- 被所有的线程共享,方法区包含所有的class(class是指类的原始代码,要创建一个类的对象,首先要把该类的代码加载到方法区中,并且初始化)和static变量。
- 方法区中包含的都是在整个程序中永远唯一的元素,如class,static变量。
常量池:在编译期间就将一部分数据存放于该区域,包含以final修饰的基本数据类型的常量值、String字符串。(在java6时它是方法区的一部分;1.7又把他放到了堆内存中;1.8之后出现了元空间,它又回到了方法区。)
静态域:存放类中以static声明的静态成员变量。
程序计数器:当前线程所执行的行号指示器。通过改变计数器的值来确定下一条指令,比如循环,分支,跳转,异常处理,线程恢复等都是依赖计数器来完成。
程序计数器:
一个非常小的内存空间,用来保存程序执行到的位置(线程私有),它可以看作是当前线程所执行的字节码的行号指示器。
Metaspace元空间:
- 在JDK1.8中,永久代已经不存在,存储的类信息、编译后的代码数据等已经移动到了MetaSpace(元空间)中,元空间并没有处于堆内存上,而是直接占用的本地内存(NativeMemory)。
- 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。
- 元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。
- 元空间的大小仅受本地内存限制,可以指定元空间大小。
- Java8为什么要将永久代替换成Metaspace?
1、字符串存在永久代中,容易出现性能问题和内存溢出。
2、类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
3、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
4、Oracle 可能会将HotSpot 与 JRockit 合二为一。
3. 举个栗子
(1)基本数据类型
int a = 3;
int b = 3;
编译器先处理 int a = 3;首先它会在栈中创建一个变量为 a 的引用,然后查找有没有字面值为3的地址,没找到,就开辟一个存放3这个字面值的地址,然后将 a 指向3的地址。
接着处理 int b = 3;在创建完 b 这个引用变量后,由于在栈中已经有3这个字面值,便将 b 直接指向3的地址。这样,就出现了 a 与 b 同时均指向3的情况。
(2)对象
public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
一个类通过使用new运算符可以创建多个不同的对象实例,这些对象实例将在堆中被分配不同的内存空间,改变其中一个对象的状态不会影响其他对象的状态。
Person one = new Person("小明",15);
Person two = new Person("小王",17);
在堆内存中只创建了一个对象实例,在栈内存中创建了两个对象引用,两个对象引用同时指向一个对象实例。
Person one = new Person("小明",15);
Person two = one;
(3)包装类
基本类型的定义都是直接在栈中,如果用包装类来创建对象,就和普通对象一样了。
比如int a = 5,a存储在栈中。
而Integer i = new Integer(5),i 对象数据存储在堆中,i 的引用存储在栈中。
(4)数组
数组是一种引用类型,数组用来存储同一种数据类型的数据。
一旦初始化完成,即所占的空间就已固定下来,即使某个元素被清空,但其所在空间仍然保留,因此数组长度将不能被改变。
int[] array = new int[5]
首先会在栈中创建引用变量,在堆中开辟5个int型数据的空间,该引用变量存放数组首地址,即实现数组名来引用数组。
4. JVM内存模型
主要变化在于:
- java8没有了永久代(虚拟内存),替换为了元空间(本地内存)。
- 常量池:1.7又把他放到了堆内存中;1.8之后出现了元空间,它又回到了方法区。
年轻代:
- 新生成的对象都放在年轻代,主要存放一些生命周期比较短的对象
- 新生代一般分三个区:一个Eden区,两个 Survivor区:
大部分对象在Eden区中生成,当Eden区满时,还存活的对象将被复制到Survivor区,当这个 Survivor区满时,此区的存活对象将被复制到另外一个Survivor区,当这个Survivor去也满了的时候,从第一个Survivor区复制过来的并且此时还存活的对象,将被复制到老年代(Tenured)。
同时,根据程序需要,Survivor区是可以配置为多个的(多于两个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。
老年代:
在年轻代中经历多次垃圾回收后仍存活的对象会被放入老年代中,一般存放生命周期较长的对象
java7永久代:
用于存放静态文件,如Java类、方法等。永久带对垃圾回收没有显著影响,一般不做垃圾回收,在JVM内存中划分空间。
java8元空间:
类似于永久带,不过它是直接使用物理内存而不占用JVM堆内存。
5. 垃圾回收机制
Java不用像C++一样自己释放内存,通过垃圾回收器GC来进行内存的回收释放。
- 不需要进行垃圾回收:程序计数器、JVM栈、本地方法栈。
- 需要进行回收垃圾的区:堆和方法区
什么时候进行垃圾回收?
- 该类的所有实例对象都已经被回收。
- 加载该类的ClassLoader已经被回收。
- 该类对应的反射类java.lang.Class对象没有被任何地方引用。
几种垃圾收集器:
- Minor GC:新生代GC,即发生在新生代(包括Eden区和Survivor区)的垃圾回收操作,当新生代无法为新生对象分配内存空间的时候,会触发Minor GC。因为新生代中大多数对象的生命周期都很短,所以发生Minor GC的频率很高,虽然它会触发stop-the-world,但是它的回收速度很快。
- Major GC:Tenured区GC,用于回收老年代,出现Major GC通常会出现至少一次Minor GC。
- Full GC:是针对整个新生代、老生代、元空间(metaspace,java8以上版本取代perm gen)的全局范围的GC。Full GC不等于Major GC,也不等于Minor GC+Major GC,发生Full GC需要看使用了什么垃圾收集器组合,才能解释是什么样的垃圾回收。
垃圾回收机制流程如下:
GC主要处理的是年轻代与老年代的内存清理操作,元空间(永久代)一般很少用GC。具体流程如下:
① 当一个新对象产生,需要内存空间,为该对象进行内存空间申请。
② 首先判断Eden区是否有有内存空间,有的话直接将新对象保存在Eden区。
③ 如果此时Eden区内存空间不足,会自动触发MinorGC,将Eden区不用的内存空间进行清理,清理之后判断Eden区内存空间是否充足,充足的话在Eden区分配内存空间。
④ 如果执行MinerGC发现Eden区不足,判断存活区,如果Survivor区有剩余空间,将Eden区部分活跃对象保存在Survivor区,随后继续判断Eden区是否充足,如果充足在Eden区进行分配。
⑤ 如果此时Survivor区也没空间了,继续判断老年区,如果老年区空间充足,则将Survivor区中活跃对象保存到老年代,而后存活区有空余空间,随后Eden区将活跃对象保存在Survivor区之中,在Eden区为新对象开辟空间。
⑥ 如果老年代满了,此时将产生MajorGC进行老年代内存清理,进行完全垃圾回收。
⑦ 如果老年代执行MajorGC发现依然无法进行对象保存,此时会进行OOM异常(OutOfMemoryError)。
上面流程就是整个垃圾回收机制流程,总的来说,新创建的对象一般都会在Eden区生成,除非这个创建对象太大,那有可能直接在老年区生成。
几种垃圾回收算法:
- 标记-清除算法(Mark-Sweep):最基础的GC算法,将需要进行回收的对象做标记,之后扫描,有标记的进行回收,这样就产生两个步骤:标记和清除。这个算法效率不高,而且在清理完成后会产生内存碎片,这样,如果有大对象需要连续的内存空间时,还需要进行碎片整理。
- 复制算法(Copying):新生代内存分为了三份,Eden区和2块Survivor区,保证有一块Survivor区是空闲的,这样,在垃圾回收的时候,将不需要进行回收的对象放在空闲的Survivor区,然后将Eden区和第一块Survivor区进行完全清理,这样有一个问题,就是如果第二块Survivor区的空间不够大怎么办?这个时候,就需要当Survivor区不够用的时候,暂时借持久代的内存用一下。此算法适用于新生代。
- 标记-整理(或叫压缩)算法(Mark-Compact):和标记-清楚算法前半段一样,只是在标记了不需要进行回收的对象后,将标记过的对象移动到一起,使得内存连续,这样,只要将标记边界以外的内存清理就行了。此算法适用于持久代。
6. 其他关于内存的小知识点
(1)Java中==和equals的区别,equals和hashCode的区别
- Java中==和equals的区别,equals和hashCode的区别
- ==用于基本数据类型用比较,比较的是值是否相等
- ==用于对象,比较的是在内存中的地址是否相等
- Equals表示引用所指内容是否相等。
以上是关于Java8-JVM内存区域划分白话解读的主要内容,如果未能解决你的问题,请参考以下文章
JVM ---- 大白话图文之JVM类加载机制内存区域垃圾回收
JVM ---- 大白话图文之JVM类加载机制内存区域垃圾回收