JVM的内存管理

内存管理

在Java中,不允许使用指针等工具来直接操作物理内存,对于内存的操作时由JVM来代我们进行处理的,虽然这样让Java的内存管理变得简单,但是这样降低了Java操作内存的效率,同时如果JVM在内存操作时出现了问题,难以找到出问题的点。

内存区域划分

JVM将内存划分为如下的区域

点击查看图片来源

内存一共分为:方法区、堆区、虚拟机栈、本地方法栈和程序计数器五个区域。

其中方法区和堆区是由所有线程共享的区域,其随着虚拟机的创建而创建,随着虚拟机的销毁而销毁。

对于虚拟机栈、本地方法栈和程序计数器是线程之间相互隔离的,每个线程都有自己的这三块区域,其生命周期与线程的生命周期保持一致。

程序计数器

JVM中的程序计数器PC和我们学计组的PC差不多是一个概念,其目的就是让Java虚拟机像物理机那样执行程序。

不同于8086CPU中PC记录下一条指令的地址,JVM中的PC可以看做是当前线程所执行的字节码的行号的指示器,每当一条指令执行完毕,就会读取下一条应该执行的指令的行号并存入PC中

Java的多线程是依靠时间片轮转算法来进行的,每个线程在其所占有的时间片终结时,会保存当前执行位置到PC中,当该线程再次抢占到CPU后,就从PC中读取断点,继续执行

**时间片轮转算法:**在多线程环境中,时间片轮转算法将每个线程分配一个固定大小的时间片,然后按照轮转的顺序让每个线程执行一个时间片的时间。当一个线程的时间片用完后,调度器会暂停该线程并将其放到队列的末尾,然后选择下一个线程执行。

时间片轮转算法的优点是公平性,每个线程都有机会执行,并且每个线程都能获得相同的时间片。这样可以确保所有线程都能获得公平的CPU时间,避免某个线程长时间占用CPU资源而导致其他线程无法执行的情况。

虚拟机栈

虚拟机栈是线程独占的。每当Java程序中的方法被执行时,JVM就会同步创建为一个栈帧(可以理解为栈里的一个元素),帧栈中包含了当前方法的一些基本信息(如局部变量表、操作数栈、动态链接和方法出口等)

局部变量表就是方法内部需要使用到的局部变量;操作数栈就是字节码执行时需要使用到的栈;每个栈帧还保存了一个指向当前方法所在类的运行时的常量池,这样在当前方法需要调用其他本类中的方法时,就可以通过运行时常量池找到对应方法的符号引用,然后将符号引用转换为直接引用,这样就能调用对应方法,这就是动态链接。最后,方法出口,顾名思义,就是方法结束的条件。

符号引用是程序设计中一个重要的概念,通常用于指代一个符号(如变量、函数、类等)的标识符,而不是直接引用其具体的实体。

在Java中,符号引用是一种抽象的概念,它表示对某个符号的引用或使用。通过符号引用,编译器或解释器可以在编译或运行时解析符号,并找到对应的具体实体(即它的对应地址),即转化为直接引用。

其实虚拟机栈就是用来记录程序执行过程的,比如main中调用了b,此时虚拟机栈中已经有了b了,然后这是将b加入虚拟机栈中执行,若b执行完成,则b出栈,最后main也出栈,这样就完成了程序的执行

image-20230306164908882

本地方法栈

本地方法栈与虚拟机栈差不多,这里直接引用GPT写的介绍

本地方法栈是JVM运行时数据区域之一,用于支持执行本地方法的调用和执行。在Java程序中,本地方法是使用其他语言(如C、C++)编写的方法,通过JNI与Java代码进行交互。本地方法通常用于实现与底层系统相关的功能,如访问硬件设备、调用操作系统接口等。

本地方法栈与Java虚拟机栈类似,但它们的作用不同。Java虚拟机栈用于支持Java方法的调用和执行,而本地方法栈用于支持本地方法的调用和执行。

堆是整个Java程序共享的区域,也是JVM内最大的一块内存空间。它的作用就是存放和管理对象数组,垃圾回收机制就是作用于这一块内存区域。

方法区

方法区也是整个Java程序共享的区域,它用于存储所有的类、常量、静态变量、动态编译缓存等数据,可以大致分为两部分,一个是类信息表,一个是运行常量池。

image-20230306164925187

类信息表用于存放当前引用程序所加载的所有类的信息,在运行时产生的新的类的数据,也会存入类信息表中。

运行时常量池会存储所有在编译时生成的常量池数据。

具体的例子

这里直接引用白马讲师的文档内容

白马讲师文档链接

其实我们的String类正是利用了常量池进行优化,这里我们编写一个测试用例:

public static void main(String[] args) {
   String str1 = new String("abc");
   String str2 = new String("abc");

   System.out.println(str1 == str2);
   System.out.println(str1.equals(str2));
}

得到的结果也是显而易见的,由于str1str2是单独创建的两个对象,那么这两个对象实际上会在堆中存放,保存在不同的地址:

image-20230306164934743

所以当我们使用==判断时,得到的结果false,而使用equals时因为比较的是值,所以得到true。现在我们来稍微修改一下:

public static void main(String[] args) {
   String str1 = "abc";
   String str2 = "abc";

   System.out.println(str1 == str2);
   System.out.println(str1.equals(str2));
}

现在我们没有使用new的形式,而是直接使用双引号创建,那么这时得到的结果就变成了两个true,这是为什么呢?这其实是因为我们直接使用双引号赋值,会先在常量池中查找是否存在相同的字符串,若存在,则将引用直接指向该字符串;若不存在,则在常量池中生成一个字符串,再将引用指向该字符串:

image-20230306164942208

实际上两次调用String类的intern()方法,和上面的效果差不多,也是第一次调用会将堆中字符串复制并放入常量池中,第二次通过此方法获取字符串时,会查看常量池中是否包含,如果包含那么会直接返回常量池中字符串的地址:

public static void main(String[] args) {
   //不能直接写"abc",双引号的形式,写了就直接在常量池里面吧abc创好了
   String str1 = new String("ab")+new String("c");
   String str2 = new String("ab")+new String("c");

   System.out.println(str1.intern() == str2.intern());
   System.out.println(str1.equals(str2));
}

image-20230306164954716

所以上述结果中得到的依然是两个true。在JDK1.7之后,稍微有一些区别,在调用intern()方法时,当常量池中没有对应的字符串时,不会再进行复制操作,而是将其直接修改为指向当前字符串堆中的的引用:

image-20230306165005169

public static void main(String[] args) {
 	//不能直接写"abc",双引号的形式,写了就直接在常量池里面吧abc创好了
   String str1 = new String("ab")+new String("c");
   System.out.println(str1.intern() == str1);
}
public static void main(String[] args) {
   String str1 = new String("ab")+new String("c");
   String str2 = new String("ab")+new String("c");

   System.out.println(str1 == str1.intern());
   System.out.println(str2.intern() == str1);
}

所以最后我们会发现,str1.intern()str1都是同一个对象,结果为true

值得注意的是,在JDK7之后,字符串常量池从方法区移动到了堆中。

最后我们再来进行一个总结,各个内存区域的用途:

  • (线程独有)程序计数器:保存当前程序的执行位置。
  • (线程独有)虚拟机栈:通过栈帧来维持方法调用顺序,帮助控制程序有序运行。
  • (线程独有)本地方法栈:同上,作用与本地方法。
  • 堆:所有的对象和数组都在这里保存。
  • 方法区:类信息、即时编译器的代码缓存、运行时常量池。

当然,这些内存区域划分仅仅是概念上的,具体的实现过程我们后面还会提到。

爆内存和爆栈

实际上,在Java程序运行时,内存容量不可能是无限制的,当我们的对象创建过多或是数组容量过大时,就会导致我们的堆内存不足以存放更多新的对象或是数组,这时就会出现错误,比如:

public static void main(String[] args) {
   int[] a = new int[Integer.MAX_VALUE];
}

这里我们申请了一个容量为21亿多的int型数组,显然,如此之大的数组不可能放在我们的堆内存中,所以程序运行时就会这样:

Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit
	at com.test.Main.main(Main.java:5)

这里得到了一个OutOfMemoryError错误,也就是我们常说的内存溢出错误。我们可以通过参数来控制堆内存的最大值和最小值:

-Xms最小值 -Xmx最大值

比如我们现在限制堆内存为固定值1M大小,并且在抛出内存溢出异常时保存当前的内存堆转储快照:

image-20230306165041598

注意堆内存不要设置太小,不然连虚拟机都不足以启动,接着我们编写一个一定会导致内存溢出的程序:

public class Main {
   public static void main(String[] args) {
       List<Test> list = new ArrayList<>();
       while (true){
           list.add(new Test());    //无限创建Test对象并丢进List中
       }
   }

   static class Test{ }
}

在程序运行之后:

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid35172.hprof ...
Heap dump file created [12895344 bytes in 0.028 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOf(Arrays.java:3210)
	at java.util.Arrays.copyOf(Arrays.java:3181)
	at java.util.ArrayList.grow(ArrayList.java:267)
	at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:241)
	at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:233)
	at java.util.ArrayList.add(ArrayList.java:464)
	at com.test.Main.main(Main.java:10)

可以看到错误出现原因正是Java heap space,也就是堆内存满了,并且根据我们设定的VM参数,堆内存保存了快照信息。我们可以在IDEA内置的Profiler中进行查看:

image-20230306165105858

可以很明显地看到,在创建了360146个Test对象之后,堆内存蚌埠住了,于是就抛出了内存溢出错误。

我们接着来看栈溢出,我们知道,虚拟机栈会在方法调用时插入栈帧,那么,设想如果出现无限递归的情况呢?

public class Main {
   public static void main(String[] args) {
       test();
   }

   public static void test(){
       test();
   }
}

这很明显是一个永无休止的程序,并且会不断继续向下调用test方法本身,那么按照我们之前的逻辑推导,无限地插入栈帧那么一定会将虚拟机栈塞满,所以,当栈的深度已经不足以继续插入栈帧时,就会这样:

Exception in thread "main" java.lang.StackOverflowError
	at com.test.Main.test(Main.java:12)
	at com.test.Main.test(Main.java:12)
	at com.test.Main.test(Main.java:12)
	at com.test.Main.test(Main.java:12)
	at com.test.Main.test(Main.java:12)
	at com.test.Main.test(Main.java:12)
	....以下省略很多行

这也是我们常说的栈溢出,它和堆溢出比较类似,也是由于容纳不下才导致的,我们可以使用-Xss来设定栈容量。

申请堆外内存

除了堆内存可以存放对象数据以外,我们也可以申请堆外内存(直接内存),也就是不受JVM管控的内存区域,这部分区域的内存需要我们自行去申请和释放,实际上本质就是JVM通过C/C++调用malloc函数申请的内存,当然得我们自己去释放了。不过虽然是直接内存,不会受到堆内存容量限制,但是依然会受到本机最大内存的限制,所以还是有可能抛出OutOfMemoryError异常。

这里我们需要提到一个堆外内存操作类:Unsafe,就像它的名字一样,虽然Java提供堆外内存的操作类,但是实际上它是不安全的,只有你完全了解底层原理并且能够合理控制堆外内存,才能安全地使用堆外内存。

注意这个类不让我们new,也没有直接获取方式(压根就没想让我们用):

public final class Unsafe {

   private static native void registerNatives();
   static {
       registerNatives();
       sun.reflect.Reflection.registerMethodsToFilter(Unsafe.class, "getUnsafe");
   }

   private Unsafe() {}

   private static final Unsafe theUnsafe = new Unsafe();
 
   @CallerSensitive
   public static Unsafe getUnsafe() {
       Class<?> caller = Reflection.getCallerClass();
       if (!VM.isSystemDomainLoader(caller.getClassLoader()))
           throw new SecurityException("Unsafe");   //不是JDK的类,不让用。
       return theUnsafe;
   }

所以我们这里就通过反射给他giao出来:

public static void main(String[] args) throws IllegalAccessException {
   Field unsafeField = Unsafe.class.getDeclaredFields()[0];
   unsafeField.setAccessible(true);
   Unsafe unsafe = (Unsafe) unsafeField.get(null);
   
}

成功拿到Unsafe类之后,我们就可以开始申请堆外内存了,比如我们现在想要申请一个int大小的内存空间,并在此空间中存放一个int类型的数据:

public static void main(String[] args) throws IllegalAccessException {
   Field unsafeField = Unsafe.class.getDeclaredFields()[0];
   unsafeField.setAccessible(true);
   Unsafe unsafe = (Unsafe) unsafeField.get(null);

   //申请4字节大小的内存空间,并得到对应位置的地址
   long address = unsafe.allocateMemory(4);
   //在对应的地址上设定int的值
   unsafe.putInt(address, 6666666);
   //获取对应地址上的Int型数值
   System.out.println(unsafe.getInt(address));
   //释放申请到的内容
   unsafe.freeMemory(address);

   //由于内存已经释放,这时数据就没了
   System.out.println(unsafe.getInt(address));
}

我们可以来看一下allocateMemory底层是如何调用的,这是一个native方法,我们来看C++源码:

UNSAFE_ENTRY(jlong, Unsafe_AllocateMemory0(JNIEnv *env, jobject unsafe, jlong size)) {
 size_t sz = (size_t)size;

 sz = align_up(sz, HeapWordSize);
 void* x = os::malloc(sz, mtOther);   //这里调用了os::malloc方法

 return addr_to_java(x);
} UNSAFE_END

接着来看:

void* os::malloc(size_t size, MEMFLAGS flags) {
 return os::malloc(size, flags, CALLER_PC);
}

void* os::malloc(size_t size, MEMFLAGS memflags, const NativeCallStack& stack) {
	...
 u_char* ptr;
 ptr = (u_char*)::malloc(alloc_size);   //调用C++标准库函数 malloc(size)
	....
 // we do not track guard memory
 return MemTracker::record_malloc((address)ptr, size, memflags, stack, level);
}

所以,我们上面的Java代码转换为C代码,差不多就是这个意思:

#include <stdlib.h>
#include <stdio.h>

int main(){
   int * a = malloc(sizeof(int));
   *a = 6666666;
   printf("%d\n", *a);
   free(a);
   printf("%d\n", *a);
}

所以说,直接内存实际上就是JVM申请的一块额外的内存空间,但是它并不在受管控的几种内存空间中,当然这些内存依然属于是JVM的,由于JVM提供的堆内存会进行垃圾回收等工作,效率不如直接申请和操作内存来得快,一些比较追求极致性能的框架会用到堆外内存来提升运行速度,如nio框架。

参考文档

走进JVM

  • 9
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值