介绍下Java内存区域(运行时数据区)

介绍下Java内存区域(运行时数据区)

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。JDK 1.8 和之前的版本略有不同。

下图是 JDK 1.8 对JVM做的改动,把方法区的具体实现----元空间已到了本地内存中。

各线程共享的:堆、方法区(元空间)、直接内存;

各线程私有的:程序计数器、虚拟机栈、本地方法栈;

1️⃣ 程序计数器

它是个什么?

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。

它有什么用?

1、字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制;

2、在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了;

补充:

  • 每个线程内都有一个程序计数器,因为每个线程各自执行的指令地址是不一样的。当多线程并发执行时,CPU中的线程调度器会给每个线程分配一个时间片,当某个线程在时间片内仍没执行完,那程序计数器就得记录当前线程下一次被分配时间片执行时的下一条jvm指令的执行地址。
有什么需要注意的点?
  • 线程私有;
  • 不会存在内存溢出(⚠ 程序计数器是JVM中唯一一个不会出现 OutOfMemoryError 的内存区域)
  • 它的生命周期随着线程的创建而创建,随着线程的结束而死亡;
  • Java中把CPU中的寄存器当作了“程序计数器”,因为寄存器是CPU中读取速度最快的单元;

2️⃣ 虚拟机栈

它是个什么?

每个线程运行时所需要的内存,称为“虚拟机栈”。所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)。

每一个线程运行时需要给每一个线程划分一块内存空间,那虚拟机栈就是每一个线程运行时所需要的内存空间。一个线程就有一个虚拟机栈,多个线程就有多个栈。

方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。

每个栈由一个个栈帧(Frame)组成,对应着每次方法调用时所占用的内存。每个栈帧内部都拥有:局部变量表、操作数栈、动态链接、方法返回地址。栈顶部的,即正在被执行的方法,叫活动栈帧。

它有什么用?

栈用来执行所有方法,并返回结果。

什么情况下会栈内存溢出?
  • 栈帧过多导致栈内存溢出。如递归调用,没有递归出口,当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误;
  • 栈帧过大导致栈内存溢出。如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常;
有什么需要注意的点?
  1. 一个方法被调用时所占用的内存就是一个栈帧,方法中有方法参数,局部变量,返回地址等这些信息都是要占用内存的。所以每个方法执行时,需要预先把内存分配好。
有没有试过线程诊断?(Linux环境下)
# 可以实时监测后台进程对CPU和内存的占用情况,top命令可以得到进程编号,但不能定位这个进程中哪个线程有问题
top	

# ps命令进一步可以查看该进程下,所有线程对CPU的占用情况,看是哪一个线程占用过高(注意:输出的线程编号是十进制的)
ps H -eo pid,tid,%cpu | grep 进程id	

# jstack命令可以得到该进程下的所有线程运行信息(注意:输出的线程中有个参数是nid,值就是线程编号,16进制的,把上面10进制换算成16进制)
jstack 进程id 	# 而且,可以定位到Java哪个类中第几行代码有问题

上面的三步法,对于常见的死循环、死锁等问题,都可以排查出来。

其他面试问题?

Q:垃圾回收是否涉及栈内存

  • 不会。因为栈内存无非就是一次次方法调用所产生的栈帧内存,而栈帧内存在每一次方法调用结束后,都会被弹出栈,即会自动被回收掉,所以根本不需要垃圾回收来管理我们的栈内存。
  • 垃圾回收只是去回收堆内存中无用的对象。

Q:栈内存分配越大越好吗

  • 栈内存划得越大,反而会让线程数变少。因为内存条物理内存大小是一定的,栈内存越大,那线程数就越少;
  • 一般栈内存划得大,只是为了进行多次的递归调用。一般采用系统默认大小就可以了,Linux下是1024kB,即1M大小;
  • 使用-Xss size可以指定栈内存的大小,如 -Xss256k(中间没有空格),把栈内存设为256k;

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

  • 如果方法内局部变量没有逃离方法的作用访问,它是线程安全的;
  • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全;

3️⃣ 本地方法栈

它是个什么?

和虚拟机栈的作用非常相似,虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务

JVM在调用本地方法时,需要给这些本地方法提供的内存空间。本地方法不是由java代码编写的,因为java有时不能直接跟OS底层打交道,所以就需要用C或C++语言编写的本地方法来与OS底层的API打交道。java代码可以通过本地方法接口调用OS底层的功能。那这些本地方法运行时使用到的内存就是本地方法栈。

它有什么用?

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowErrorOutOfMemoryError 两种错误。

4️⃣ 堆(🚀🚀🚀)

它是个什么?

简称 Heap,JVM中内存最大的一块;堆是所有线程共享的一块内存区域,在虚拟机启动时创建;

Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。

它有什么用?

此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。

Q:为什么说几乎,对象不就是在堆中存放的吗?

  • 随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在上分配内存。
JDK1.8和1.8之前的区别?

JDK 1.6 及以前,存在永久代;

在 JDK 1.7,有永久代,但已经逐步“去永久代”。堆内存通常分为三个部分:新生代(伊甸园区、幸存区)、老年代、永久代;

JDK 1.8 及以后,移除 PermGen(永久代), 被 Metaspace(元空间) 取代,元空间使用的是“直接内存”,受本机物理内存大小限制。

什么情况下堆会溢出?

堆这里最容易出现的就是 OutOfMemoryError 错误 ,有多种情况:

  • java.lang.OutOfMemoryError: GC Overhead Limit Exceeded : 当 GC 花费大量时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误;
  • java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误;

补充:

  • -Xmx8m:最大堆内存可通过-Xmx参数配置,这里例子是设成8M,没有配置,将会使用默认值;
  • 它是线程共享的,堆中对象都需要考虑线程安全的问题;
  • 堆中不再被引用的对象都会被当作垃圾回收,释放得到空闲内存,使得内存不会被OOM;
堆内存诊断工具知道哪些?
jps 	// 查看当前系统中有哪些Java进程,并且显示它们的进程编号

jmap -heap 进程id	// 拿到Java进程编号后,通过jmap工具可以查看这个Java进程的堆内存使用情况(只能查看某一个时刻)

使用方式:直接在控制台上运行上面两条命令即可。

jconsole	// 相比jmap能实时查看某个Java进程的堆内存使用情况

使用方式:控制台输入 jconsole 命令,打开一个图形化界面,选择本地要查看的进程,连接,选择不安全连接,此时就可以看到堆内存的使用了(可以查看实时的堆内存变化)。

jps和jmap是以命令行的方式操作的,而jconsole是以图形界面操作的。

测试代码:

public static void main(String[] args) {
    System.out.println("1...");
    Thread.sleep(20000);
    byte[] arr = new byte[1024 * 1024 * 10];	// 10Mb
    System.out.println("2...");
    Thread.sleep(20000);
    arr = null;
    System.gc();	// 手动gc,回收堆中的 byte 数组
    System.out.println("3...");
    Thread.sleep(20000000);
}

5️⃣ 方法区

它是个什么?

方法区是各个线程共享的内存区域,它在虚拟机启动时被创建(跟堆一样);

它有什么用?

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

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

方法区是一种规范,是抽象的概念。而永久代和元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种具体实现方式。永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。

Q:为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢

  • 整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整(元数据和class对象存放在永久代中,容易内存溢出),而元空间使用的是直接内存,受本机可用内存(内存条)的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小;
  • 元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了;
方法区常用参数有哪些?

JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小:

-XX:PermSize=N 		// 方法区 (永久代) 初始大小
-XX:MaxPermSize=N // 方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen

JDK 1.8 的时候,方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存:

-XX:MetaspaceSize=N 	//设置 Metaspace 的初始(和最小大小)
-XX:MaxMetaspaceSize=N 	//设置 Metaspace 的最大大小
运行时常量池是什么?

常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息;

常量池表会在类加载后存放到方法区的运行时常量池中。

运行时常量池是方法区的一部分,自然受到方法区内存的限制(1.8以后为直接内存),当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误;

字符串常量池是什么?

字符串常量池 是 JVM 为了提升性能减少内存消耗 针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。

// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";	// aa是对象的引用,存在栈中
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true

注:StringTable 中保存的是字符串对象的引用,字符串对象的引用指向堆中的字符串对象。

JDK1.7 之前(1.6),字符串常量池存放在永久代(方法区)。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。

Q:JDK 1.7 为什么要将字符串常量池从永久代移动到堆中

主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。

Q:对于StringTable,如何性能调优

StringTable 本质上就是一个HashSet<String> ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置)。

  • 调整 -XX:StringTableSize= 桶个数
    • StringTable底层是一个哈希表,如果桶的个数比较多,那元素相对分散,哈希碰撞的几率就会减少,查找速度也会变快;反之,如果桶的个数少,哈希碰撞的几率就会增大,查找速度也会变慢;
    • 如果字符串常量的个数非常多,可以把桶个数调的比较大,这样让他们有一个很好的哈希分布,减少哈希冲突,查询效率就会大幅提升;
  • 考虑将字符串对象是否入池
    • 如果某应用中有大量字符串,而且字符串可能会存在重复时,可以让字符串入StringTable,减少字符串的个数,节约堆内存的使用。

6️⃣ 直接内存

搞懂 直接内存是什么?有什么用?直接内存的分配和释放如何实现的?

是什么?

Direct Memory

  • 常用于数据缓冲区操作,用于数据缓冲区;
  • 分配和回收的成本高,但读写性能高(比传统阻塞式IO性能要高);
  • 不受 JVM 内存回收管理;
有什么用?

直接内存常用于NIO操作,在NIO进行数据读写时用于数据缓冲区内存。例如NIO中有个类叫ByteBuffer,ByteBuffer所分配和使用的内存就是直接内存。

是否会内存溢出?

直接内存并不属于 Java 虚拟机的内存,而是属于操作系统内存。本地直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

import java.nio.ByteBuffer;
import java.util.*;
/**
 * 演示直接内存溢出
 */
public class Demo1_10 {
    static int _100Mb = 1024 * 1024 * 100;

    public static void main(String[] args) {
        List<ByteBuffer> list = new ArrayList<>();
        int i = 0;
        try {
            while (true) {
                ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
                list.add(byteBuffer);
                i++;
            }
        } finally {
            System.out.println(i);	// 打印循环多少次,会报内存溢出(不同工作环境、不同配置的电脑输出不同)
        }
        // 方法区是jvm规范, jdk6 中对方法区的实现称为永久代
        //                  jdk8 对方法区的实现称为元空间
    }
}
如何分配和释放?

直接内存的分配和释放是通过 Unsafe 类来管理的,直接内存不会自动释放,必须手动调用 freeMemory()方法释放内存。而垃圾回收只能释放 JVM中的堆内存。看下面的例子:

public class Demo1_27 {
    
    static int _1Gb = 1024 * 1024 * 1024;

    public static void main(String[] args) throws IOException {
        // 通过反射拿到 Unsafe 对象实例
        Unsafe unsafe = getUnsafe();
        // 分配内存1个G,可以打开任务管理器查看进程情况
        long base = unsafe.allocateMemory(_1Gb);
        unsafe.setMemory(base, _1Gb, (byte) 0);
        System.in.read();	// 程序运行阻塞,控制台回车才会继续向下执行

        // 释放内存
        unsafe.freeMemory(base);
        System.in.read();
    }

    public static Unsafe getUnsafe() {
        try {
            // theUnsafe 是 Unsafe 类中一个私有的静态成员变量,类型是 Unsafe
            Field f = Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            Unsafe unsafe = (Unsafe) f.get(null);
            return unsafe;
        } catch (NoSuchFieldException | IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }
}

查看下 ByteBuffer类的allocateDirect方法的源码:

/* ByteBuffer.java */
public static ByteBuffer allocateDirect(int capacity) {
    return new DirectByteBuffer(capacity);
}

/* DirectByteBuffer.java */
DirectByteBuffer(int cap) {
    ......
	try {
            base = unsafe.allocateMemory(size);	// 调用了Unsafe类的方法分配内存
        } catch (OutOfMemoryError x) {
            Bits.unreserveMemory(size, cap);
            throw x;
        }
    unsafe.setMemory(base, size, (byte) 0);	// 调用了Unsafe类的方法分配内存
    ......
	cleaner = Cleaner.create(this, new Deallocator(base, size, cap));	// 释放内存时会执行这里
    att = null;
}

/*	Cleaner.class	*/
public void clean() {
    if (remove(this)) {
        try {
            this.thunk.run();
            .....
            
/* DirectByteBuffer.java    */
private static class Deallocator implements Runnable {
    public void run() {
        if (address == 0) {
            // Paranoia
            return;
        }
        unsafe.freeMemory(address);		// 这里调用了 freeMemory 释放直接内存
        address = 0;
        Bits.unreserveMemory(size, capacity);
    }
}

ByteBuffer 实现类内部,使用了Cleaner虚引用来监测 ByteBuffer 对象,一旦 ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程(守护线程)执行 Cleaner 类的 clean 方法,内部调用 freeMemory() 来释放直接内存;

ByteBuffer类:它是抽象类

public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer>

Cleaner类:它是虚引用类型(继承 PhantomReference<Object>

public class Cleaner extends PhantomReference<Object> 

参考

https://javaguide.cn/java/jvm/memory-area.html#前言

https://www.bilibili.com/video/BV1yE411Z7AP

posted @ 2022-08-21 19:53  阿飞的客栈  阅读(554)  评论(0编辑  收藏  举报