Jvm篇--内存管理
内存管理
Java中所有的变量其实本质都是由存储在内存之中,而为什么我们不需要去管理内存的位置或者相关信息呢?
是因为Java帮我们自动把这些问题给处理掉了,不再需要我们手动对内存进行管理分配等操作
这样做有好处也有一定的坏处
- 不需要再去手动分配管理内存地址和内存相关信息内容,只需要专心完成代码逻辑即可
- 但是如果内存方面出现了问题,那么就说明jvm中内存管理出现了错误,这时候修复相关问题是没有手动管理来的方便的
所以只有了解了JVM的内存管理机制,我们才能够在出现内存相关问题时找到解决方案
内存区域划分
虚拟机运行时,会对内存区域进行以下划分
线程独立数据区
程序技术器
其作用主要是给当前线程的运行位置进行标记,因为java中的多线程采用的算法也是 时间片轮转算法
因此一个CPU同一时间也只会处理一个线程,当某个线程的时间片消耗完成后,会自动切换到下一个线程继续执行,而当前线程的执行位置会被保存到当前线程的程序计数器中,当下次轮转到此线程时,又继续根据之前的执行位置继续向下执行。
- 生命周期为随着线程运行而产生,随着线程销毁而销毁。
- 该功能因为只记录线程运行位置,所以只占很小部分内存
虚拟机栈/本地方法栈
虚拟机栈就是一个非常关键的部分,看名字就知道它是一个 栈结构(先进先出)
每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(其实就是栈里面的一个元素)
栈帧中包括了当前方法的一些信息,比如局部变量表、操作数栈、动态链接、方法出口等。
本地方法栈与虚拟机栈作用差不多
线程共享数据区
堆
堆是整个Java应用程序共享的区域,也是整个虚拟机最大的一块内存空间,而此区域的职责就是存放和管理对象和数组,而我们马上要提到的垃圾回收机制也是主要作用于这一部分内存区域(但不是唯一作用该区域)。
- Java对象会放在该内存区域中
- 此区域属于Java内存中最大一块区域,也是操作最多的区域,因为垃圾回收机制。
方法区
方法区也是整个Java应用程序共享的区域,它用于存储所有的类信息、常量、静态变量、动态编译缓存等数据,可以大致分为两个部分,一个是类信息表,一个是运行时常量池。
首先类信息表中存放的是当前应用程序加载的所有类信息,包括类的版本、字段、方法、接口等信息,同时会将编译时生成的常量池数据全部存放到运行时常量池中。当然,常量也并不是只能从类信息中获取,在程序运行时,也有可能会有新的常量进入到常量池。
其实我们的String类正是利用了常量池进行优化,这里我们编写一个测试用例:
public static void main(String[] args) {
String str1 = new String("abc");
String str2 = new String("abc");
System.out.println(str1 == str2); // false
System.out.println(str1.equals(str2)); // 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
,这是为什么呢?这其实是因为我们直接使用双引号赋值,会先在常量池中查找是否存在相同的字符串,若存在,则将引用直接指向该字符串;若不存在,则在常量池中生成一个字符串,再将引用指向该字符串:
实际上两次调用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));
}
所以上述结果中得到的依然是两个true
。在JDK1.7之后,稍微有一些区别,在调用intern()
方法时,当常量池中没有对应的字符串时,不会再进行复制操作,而是将其直接修改为指向当前字符串堆中的的引用:
总结
各个内存区域的用途:
- (线程独有)程序计数器:保存当前程序的执行位置。
- (线程独有)虚拟机栈:方法调用时才会插入栈帧。通过栈帧来维持方法调用顺序,帮助控制程序有序运行。
- (线程独有)本地方法栈:同上,作用与本地方法。
- 堆:所有的对象和数组都在这里保存。
- 方法区:类信息、即时编译器的代码缓存、运行时常量池。
内存溢出/栈溢出
内存溢出
在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大小(JDK17往上请设置2M),并且在抛出内存溢出异常时保存当前的内存堆转储快照:
然后再生成无限对象存入列表中
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中进行查看
栈溢出
我们接着来看栈溢出,我们知道,虚拟机栈会在方法调用时插入栈帧,那么,设想如果出现无限递归的情况呢?
public class Main {
public static void main(String[] args) {
test();
}
public static void 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;
}
通过反射获取该类:
public static void main(String[] args) throws IllegalAccessException {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
}
使用:
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));
}
所以说,直接内存实际上就是JVM申请的一块额外的内存空间,但是它并不在受管控的几种内存空间中,当然这些内存依然属于是JVM的,由于JVM提供的堆内存会进行垃圾回收等工作,效率不如直接申请和操作内存来得快,一些比较追求极致性能的框架会用到堆外内存来提升运行速度,如nio框架。