Java——JVM内存详解

Posted Yawn,

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了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表示引用所指内容是否相等。

以上是关于Java——JVM内存详解的主要内容,如果未能解决你的问题,请参考以下文章

详解Jvm内存结构

Java 虚拟机内存区域划分详解

Java跨平台根本原因,面试必问JVM内存模型白话文详解来了

Jvm(30),理解升级----Java中堆内存和栈内存详解

Java常量池详解

Java虚拟机详解02----JVM内存结构