JVM内存模型详解

392 阅读19分钟

JVM内存模型

本文描述了JVM内存模型的定义,并对一些提问进行了说明。

JVM,Java文件先通过javac编译器,将代码编译成Class文件,然后通过JVM将Class文件解释成各个平台可以识别的机器码,来实现跨平台运行代码,步骤如下:

  1. Java文件
  2. javac编译器
  3. Class文件
  4. JVM
  5. 机器码

以JDK1.8为例:

线程私有的部分是:程序计数器、虚拟机栈、本地方法栈

线程共有的是:堆、方法区

程序计数器

在多线程中会存在线程的上下文切换,即CPU暂停执行当前线程,转而执行其他线程的代码。

在发生上下文切换时,需要保存当前线程的执行状态,并恢复另一个线程的状态,Java中对应的概念就是程序计数器,他的作用是在程序执行过程中,记住下一条JVM指令的执行地址。

特点

线程私有、不会存在内存的溢出

为什么程序计数器不会存在内存溢出的情况?

因为程序计数器记录的是虚拟机字节码指令的地址,并且是线程私有的,在程序计数器创建的时候就能分配一个绝对不会溢出的内存,并且计数器中存储的数据所占空间大小不会随着程序的运行而改变,所以不会存在内存溢出的问题。

什么是内存溢出问题?

一些关于内存溢出的问题和解决方法:

  1. 内存中加载的数据量过于庞大,JVM没有这么多的空间存储,如一次性从数据库中查询过多的数据

    :采用分页查询,避免一次性取出过多数据

  2. 集合类中有对对象的引用,使用完后为清空,使得JVM不能回收

    :进行白盒测试,以及检查程序代码

  3. 代码中存在死循环或循环产生太多重复的对象

    :使用内存工具查看内存使用情况,判断是否有内存泄漏问题

  4. JVM启动参数设定的太小

    :修改JVM启动参数,增加内存(-Xms -Xmx)

虚拟机栈

栈是先进后出的数据结构,每个线程运行时所需要的内存,称为虚拟机栈

在JVM中,栈是线程运行需要的内存空间,一个线程需要一个栈,多个线程有多个栈

栈里面存放的称为栈帧,每个方法运行时需要的内存称为一个栈帧(里面包含参数、局部变量、返回地址)

每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

垃圾回收是否会对栈内存进行回收?

不会,栈内存自己会把用过的栈帧弹出释放

栈内存设置的越大越好吗?

不是,栈内存(-Xss)在Linux中默认为1024KB即1M,栈内存分配的越多,线程数就会越小,因为假设物理内存有500M,那么默认1M的栈内存大小,JVM最多可以创建500个线程,当栈内存设置为2M时,JVM最多就只能创建250个线程

方法内的局部变量是否线程安全?

是,因为每个线程都有自己的私有的局部变量,如StringBuilder,在方法内作为局部变量,那么它就是线程安全的,但是假如这个StringBuilder作为参数或者返回值,那么它就不是线程安全的,因为他有可能被多个线程同时访问,这种时候可以改用StringBuffer线程安全类型

栈内存溢出的场景有哪些?

  • 栈帧过多导致栈内存溢出,如没有终止条件的递归调用
  • 栈设置的内存小,栈帧过大导致栈内存溢出

本地方法栈

本地方法栈的作用是为本地方法的调用提供内存空间,即native关键字,声明这个方法不由Java实现,而是用C/C++或其他语言来实现。

堆(Heap)

通过new关键字创建的对象都会使用堆内存

堆空间设置:-Xmx256m

特点

  • 线程共享,堆中对象都要考虑线程安全问题
  • 有垃圾回收机制

堆内存溢出的场景?

如for循环中,无终止条件的给数组对象添加元素,直至堆内存溢出。

堆内存问题如何诊断?

  1. jsp工具,查看当前系统中有哪些Java进程、
  2. jmap工具,查看堆内存占用情况
  3. 或者用jconsole图形可视化工具

方法区

方法区是JVM运行时数据区域的一块逻辑区域,是线程共享的内存区域

当虚拟机要使用一个类时,它需要读取并解析Class文件获取相关信息,再将信息存入到方法区,方法区会存储已被虚拟机加载的:类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

方法区和永久代以及元空间有什么关系?

方法区和永久代以及元空间的关系就像Java中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是HotSpot虚拟机对虚拟机规范中方法区的两种实现方式。

并且永久代是JDK1.8之前的方法区实现方式,JDK1.8及之后就改用元空间实现了。

方法区的常用设置参数:

-XX:MetaspaceSize=初始和最小大小
-XX:MaxMetaspaceSize=设置Metaspace的最大大小

与永久代很大的不同是,元空间如果不指定大小,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。

为什么要将永久代(PermGen)换成元空间(Metaspace)?

永久代受JVM设置的固定大小上限,虽然可以通过参数来设置,但是JVM加载类的总数是很难确定的,所以容易出现OOM问题;而元空间是存储在本地内存中,内存上限更大,可以很好的避免这个问题,但不能说可以彻底解决OOM,因为元空间一样会出现内存使用完的情况,只是相比起永久代,对解决内存溢出的问题会有所优化。

常量池

JVM将Class文件反编译为JVM指令码后,Class的常量池就是记录着该类的一些常量、方法描述、类描述、变量描述信息的表,

常量池中主要存放两类数据:字面量和符号引用

字面量: 比如String类型的字符串值或者定义为final类型的常量的值

符号引用: Java类并不知道引用类的实际内存地址,所以使用符号引用来代替,比如org.simple.People类引用org.simple.Tool类,在编译时People类并不知道Tool类的实际内存地址,所以只能用符号(假设为org.simple.Tool)来表示,然后在类加载器装载People类的时候,可以通过虚拟机获取Tool类的实际内存地址,因此便可以将刚刚所用的符号替换Tool类的实际内存地址,即实际引用地址。

也就是说,符号引用是用来在编译时被当作类的内存地址的别名,在类装载的时候会用真正的内存地址替换符号引用

运行时常量池(元空间)

上面可以知道常量池就是一张对照表,当类的字节码被加载到内存中后,常量池信息就放到一块内存中(元空间),这块内存就称为运行时常量池,并且把里面的符号替换为真实内存地址。

字符串池(堆)

StringTable在堆中,是HashTable结构,不可扩容,

JVM将Class常量池信息放到运行时常量池的时候,会去查询全局字符串池,以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。

字符串常量池:每个JVM中只有一份,存在于方法区的堆中,全局字符串池里的内是在类加载时完成的。

StringTable中存储的不是String类型的对象,存储的是指向String对象的指针,真实对象还是存储在堆中。

字符串池的经典题目

以下代码分别创建了多少个对象?

  1. String s = "ab";

    :一个"ab"对象。存放到字符串池(StringTable)中

  2. String s = "a" + "b";

    :一个"ab"对象。在编译时就会优化成"ab",存放到字符串池中(StringTable)

  3. String s = new String("ab");

    :两个对象。一个是用new关键字在堆空间中创建的,一个是字符串池中的"ab"对象

  4. String s=new String("a")+new String("b");

    :六个对象。首先是"a"和"b"两个字符串池对象,然后是两个new关键字创建的堆空间对象,然后字节码中显示用new StringBuilder()去拼接两个字符串对象,然后用toString()方法返回, toString()底层也是new String(),所以一共创建了六个对象。

字符串比较的演示

public static void main(String[] args) {
  String s1 = "a";
  String s2 = "b";
  String s3 = "a" + "b";
  String s4 = s1 + s2;
  String s5 = "ab";
}

我们编译后,在IDEA中查看Class文件:

public static void main(String[] args) {
  String s1 = "a";
  String s2 = "b";
  String s3 = "ab";
  (new StringBuilder()).append(s1).append(s2).toString();
  String s5 = "ab";
}

结论:

可以看到,s1和s2变量都是字符串常量,存放到字符串常量池;

s3属于常量运算,在编译时就会被替换成"ab",也是存放到字符串常量池中;

s4则是用StringBuilder做拼接,然后返回toString()方法的结果,我们去看看toString()的源码实现:

@Override
public String toString() {
  // Create a copy, don't share the array
  return new String(value, 0, count);
}

toString()底层是new了一个String返回值,所以在上面创建的几个变量中,比较结果为:

s3 == s5
s3 != s4

类加载器

三种类加载器

  • 启动类加载器:C++实现,负责加载JAVA_HOME/lib下的类
  • 拓展类加载器:JAVA实现,负责加载JAVA_HOME/lib/ext下的类
  • 系统类加载器/应用程序加载器:负责加载我们写的代码

类加载的流程

如果一个类加载器收到了类加载请求,他并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父类的加载器还存在其父类加载器,则进一步向上委托,依次递归,最终到达顶层的启动类加载器。如果父类加载器可以完成类加载任务,就成功返回,若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式

双亲委派的好处

  • 因为是向上委托加载的,所以可以确保每个类都只会被加载一次,避免了重复加载
  • 有效避免了某些恶意类的加载,比如自定义了java.lang.Object类,一般在双亲委派模型下会加载系统的Object类而不是自定义的Object类,避免了被恶意篡改核心包的风险

如何破坏双亲委派模型?

先讨论为什么要打破?

比如数据库驱动Driver接口,Driver定义在JDK中,但是实现的却是各个数据库服务商,如MySQL的MySQL Connector,所以就有一个问题,DriverManager要加载各个Driver接口实现类,然后进行管理,但是DriverManager是由启动类加载器进行加载的,这个启动类加载器默认加载JAVA_HOME下面的lib,但我们要加载的各个实现类需要用应用程序加载器来加载,这时候就需要打破双亲委派。

打破方式:

  1. 自定义类加载器,重写loadClass方法

    JDK1.2之前还没有双亲委派模型,但由ClassLoader抽象类,所以继承这个抽象类,重写loadClass方法来实现用户自定义类加载器。因为双亲委派的逻辑在loadClass上,所以通过重写loadClass可以打破双亲委派逻辑

  2. 使用线程上下文类(contextClassLoader)

    双亲委派很好的解决了各个类加载器的基础类同一问题(越基础的类由越上层的加载器加载),基础类之所以基础,是因为他们总是作为被调用的API,但是如果基础类又要调用用户的代码该怎么办呢?

    比如JNDI是java的标准服务,它的代码是由启动类加载器加载的,但是他需要调用由开发人员开发在classpath下的类代码,而这些代码启动类加载器不会加载,所以引入了线程上下文类,类加载器通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程的时候还未设置,它将会从父线程中继承一个,如果在应用程序全局范围内没有设置,那么这个线程上下文类加载器默认就是应用程序类加载器。

    这样JNDI服务使用这个线程上下文类加载器去加载所需的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,也是打破了双亲委派模型。

垃圾回收

垃圾回收的目的是为了识别和丢弃程序不再需要的对象,回收和复用内存资源。

如何判断垃圾可以回收?

  • 引用计数法

    给Java对象添加一个引用计数器,每当有一个地方引用它时,计数器+1,引用失效则-1,当计数器为0时,判断该对象死亡,则可以释放内存。

    缺点是无法解决对象相互循环引用的问题,正因为此,JVM并没有采用该算法来判断Java对象是否存活。

    什么是相互循环引用:创建A数组对象arr1,B数组对象arr2,arr1.add(arr2),arr2.add(arr1),两个对象互相持有,引用计数都为1,都在等待对方释放,这种情况永远都不会被判断可以释放,容易导致内存溢出问题。

  • 可达性分析算法

    扫描堆中的对象,看是否能沿着GC Root对象为起点的引用链找到该对象,找不到则表示可以回收

    可以作为GC Root对象的有:

    1. 系统类加载器加载的类
    2. JVM方法区中静态属性引用的对象
    3. JVM常量池中引用的对象
    4. JVM虚拟机栈中引用的对象
    5. JVM本地方法栈中引用的对象
    6. 活动着的线程

    在可达性分析中判断不可达后,会被第一次标记,等待回收,即该对象是否需要执行finalize()方法。

可达性分析算法如何解决相互循环引用的问题?

算法中定义了一些GC Root对象,这几个root对象在GC时不会被JVM回收掉,然后通过这些对象像树枝一样向外延伸,被引用到的对象说明还存活使用,就不会被GC,没有被这些root对象引用到的就会被GC掉,从而解决了循环引用的问题。

垃圾回收的三种类型?

minor GC、major GC和Full GC

对新生代进行垃圾回收叫minor GC;对老年代进行垃圾回收叫major GC,同时对新生代和老年代进行垃圾回收叫Full GC,Full GC时间一般较长,所以我们需要尽量避免Full GC事件的发生。

为什么要进行垃圾回收,垃圾回收是如何进行的?

程序运行过程中,不断的分配内存而不进行释放,会导致可用内存越来越小,直到导致内存溢出问题(OOM)。

那么什么时候进行垃圾回收呢?上面说到,new关键字创建的对象会存放到堆空间中,堆区分为以下几个部分:

  • 新生代(三个部分),共占堆空间的1/3
    • Eden区(伊甸园)
    • s0区(幸存者1区)
    • s1区(幸存者2区)
  • 老年代,占堆空间的2/3,是新生代的两倍

现在说一下垃圾回收流程:

首先,伊甸园、s0、s1、老年代内的空间均为空,即没有对象

  1. new出来的对象先放到伊甸园里面,当伊甸园内存满了之后,释放伊甸园和s1中可以回收的对象,存活对象放到空的s0幸存者区中,然后交换s1幸存者区的位置,并标记存活对象的寿命为1

    此次GC(Minor GC)后,伊甸园空间为空,s0空间为空,s1空间内有该次GC存活的对象,老年代为空

  2. 然后伊甸园继续创建对象,再次满了,重复步骤1

    第二次GC后,伊甸园空间为空,s0空间为空,s1空间内有两次GC都存活的对象,老年代为空。但这一次,该次GC存活的对象寿命为1,上次存活的对象寿命加1,即寿命为2

  3. 重复以上步骤n次,直到出现寿命为15(默认,可配置)的对象,则将其放入老年代中

    第n次GC后,伊甸园空间为空,s0空间为空,s1空间内为寿命不一的存活对象,老年代中有寿命为15的存活对象

  4. 当数据达到一定量,新创建的对象放不进伊甸园,放不进幸存者区,也放不进老年代时,则会进行一次Full GC,对所有数据进行一次垃圾回收

知识点:

  • Minor GC会引发一次stop the world即咋瓦鲁多,暂停其他用户的线程,等垃圾回收结束后用户线程才恢复
  • 当老年代空间不足时会先尝试进行Minor GC,如果空间仍然不足则触发Full GC,Full GC咋瓦鲁多的时间更长

垃圾回收的四种引用类型

强应用:

通过new对象创建的引用,只要沿着GC Root可以找到该对象,则不会被垃圾回收,即使是内存不足

Employee employee = new Employee();

软引用:

通过SoftRefrence实现,生命周期比强应用短,当发生垃圾回收且内存不够时,则会对其进行回收

SoftReference<Employee> employeeSoftReference = new SoftReference<>(employee);

弱引用:

通过WeakRefrence实现,生命周期比软引用还短,GC只要扫描到弱引用的对象就会回收。

软引用和弱引用常见的使用场景是存储一些内存敏感的缓存,比如将查询结果放入内存中,如果用强引用,则容易造成OOM,而使用软引用、弱引用,在内存不足时会被GC掉,避免OOM。

WeakReference<Employee> employeeWeakReference = new WeakReference<>(employee);

虚引用:

通过PhantomRefrence实现,生命周期最短,随时可能被回收。如果一个对象被虚引用引用,我们无法通过虚引用来访问这个对象的任何属性和方法,getXXX()返回的都是null,它的作用仅仅是保证对象在finalize()后做一些事情。

常见的使用场景是跟踪对象被垃圾回收的活动,当一个虚引用关联的对象被垃圾回收器回收之前会收到一条系统通知。

private ReferenceQueue<? super Employee> referenceQueue;

public void test() {
    referenceQueue = new ReferenceQueue<>();
    PhantomReference<Employee> employeePhantomReference = new PhantomReference<>(employee, referenceQueue);
}

垃圾回收算法有哪些

标记清除:

第一遍将没有被GC Root引用的对象做一个标记,第二遍将被标记的对象进行回收。

优点是速度快,缺点是容易产生内存碎片,内存碎片会降低系统性能,无法满足程序需要分配连续空间请求。

标记整理:

在标记清除算法的基础上,清除后进行碎片的整理。

优点是不会产生内存碎片,缺点是整理需要移动对象地址,效率低速度慢。

复制:

开辟一样的内存,将不需要回收的对象内存地址拷贝过来,拷贝过来的时候就已经做了整理,所以不会有内存碎片。

优点:

  • 解决了内存碎片的问题,新的内存空间,自然没有内存碎片
  • 吞吐量高,标记算法需要不断的遍历,而复制算法不用,只在GC时搜索一次活动对象,速度快,并且吞吐量高
  • 分配效率高,不需要寻找空闲链表,直接就开始分配,当然快

缺点:

  • 消耗两倍内存,需要的内存空间大了,是一种空间换时间的思想

分代回收:

新生代、老年代使用不同的算法,提高内存使用效率

新生代中绝大部分对象都会在很短时间内变成垃圾,所以使用复制算法,速度快

老年代中的对象存活时间长,垃圾率低,所以每次垃圾回收的数量不会很多,可以选择标记整理算法

垃圾回收器

以Java8为例,主要分四种:

  • 串行(Serial)
  • 并行(Parallel)
  • 并发(CMS)
  • G1

串行垃圾回收器:

为单线程环境设计,只有一条垃圾回收线程来进行垃圾回收,会暂停所有用户线程。

用于堆内存较小的场景,不适合服务器环境。

并行垃圾回收器:

多个垃圾回收线程进行垃圾回收工作,用户线程暂停,适合大数据处理这类若交互的场景

并发(CMS)垃圾回收器:

用户线程和垃圾回收线程同时执行,不需要暂停用户线程,多用于互联网服务场景,适用于对响应时间有要求的场景。

CMS是以获取最短回收时间为目标的收集器,基于标记清除算法实现,比较占用CPU资源,且容易造成内存碎片。

G1垃圾回收器:

将内存分为等大的区域,优先回收垃圾最多的区域,减少用户线程暂停的时间。

如何选择垃圾回收器?

没有最合适的垃圾回收器,所有的垃圾回收器都会造成用户线程暂停,我们需要根据不同的场景需要来选择更为合适的垃圾回收器,比如:

  • 希望稳定高效率的选择串行垃圾回收器,但是使用场景比较小,用户暂停时间最长
  • 希望增加吞吐量的(吞吐量 = 代码运行时间 / (代码运行时间+垃圾收集时间)),也就是高效率利用CPU时间,尽快完成程序运算任务,在单位时间内暂停的时间最短,则可以选择并行垃圾回收器
  • 希望增加响应时间,尽可能让单次响应的时间最短,则可以选择并发(CMS)垃圾回收器,CMS中,暂停只为了标记,清理垃圾的时间放在了并发中
  • G1在Java9中成为了默认的垃圾回收器,比CMS更高,但是对CPU性能要求也更高,针对大内存、多处理器的机器环境,可以选择G1