Java虚拟机
Java虚拟机
内存模型
Java虚拟机也就是JVM,在开始这部分学习之前,我们应该联想回顾一下Java基础篇学习的jdk、jre、jvm三者之间的关系。
在此,我们用一张图简洁明了的展示其三者的关系:
补充:其中javac即编译器
除此之外,我们对内存的堆栈也进行复习:
堆区和栈区本质都是内存的一块区域,只是用法不同。
- 栈区:用于存储函数中的局部变量,用到该函数时将其局部变量放入栈区,使用完成后自动销毁,符合先入后出、后入先出的原则(栈规则),其不需要手动控制,性能较高。其空间大小一般由操作系统控制,大小是一定的,所以创建局部变量的大小不能过大否则可能导致栈溢出。
- 堆区:用于存储超越函数的变量,例如:
这是我们手动获取的一部分内存空间,回收也是我们手动控制,性能较低。并且所有线程共享一个堆区,要考虑线程安全的问题。堆区内存容量更大,不受物理内存限制,但是创建的变量要手动释放,否则可能内存泄露。
JVM的内存模型
JVM (Java Virtual Machine)即Java虚拟机,可以理解为一个虚拟的小型计算机,计算机应该有的它也应该有,所以其有内存也就不难理解了。
JVM运行时分为虚拟机栈、堆、元空间(也叫方法区,暂时不懂有待深入学习)、程序计数器、本地方法栈(暂时不懂有待深入学习)五个部分。还有一部分内存是直接内存,属于操作系统的本地内存,也是可以直接操作的。
放一张图,不懂就抓紧继续学:
JVM的内存结构各部分及其作用:
- 程序计数器(The pc register):可以看作是当前线程所执行的字节码的行号指示器,用于存储当前线程正在执行的 Java 方法的 JVM 指令地址。如果线程执行的是 Native 方法,计数器值为 null。是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域,生命周期与线程相同。(你可以把它理解为线程存档,记录该线程执行到了哪条JVM指令地址)
- Java虚拟机栈Stack:JVM中的栈用来存储的是int、byte、char等基本类型数据以及对堆中对象实例的引用(指向实例地址,类似于指针),其是线程私有的(局部的)。每个线程都有自己独立的 Java 虚拟机栈,生命周期与线程相同。每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。可能会抛出 StackOverflowError 和 OutOfMemoryError 异常。
- Java堆Heap:JVM中堆用来存储对象实例(Object实例),这部分是共享的。是 JVM 中最大的一块内存区域,被所有线程共享,在虚拟机启动时创建,用于存放对象实例。从内存回收角度,堆被划分为新生代和老年代,新生代又分为 Eden 区和两个 Survivor 区(From Survivor 和 To Survivor)。如果在堆中没有内存完成实例分配,并且堆也无法扩展时会抛出 OutOfMemoryError 异常。
- 方法区MethodArea[永久代PermGen(1.8之前)->元空间Metaspace(1.8及以后)]: JDK 1.8 之前,使用永久代来实现方法区,其内存是堆空间的一部分,在 JDK 1.8 及以后的版本中,实现了元空间,其使用本地内存。方法区用于存储已被虚拟机加载的类信息、常量、静态变量等数据。方法区可以选择不实现垃圾收集,内存不足时会抛出 OutOfMemoryError 异常。存储的是类的元信息,联想一下Java。方法区这个概念你可以理解为一个抽象规范,而永久代和元空间是对其的实现。
- 本地方法(的)栈Native Method Stack:这个跟JVM的栈和方法区没有联系,只是命名类似。他是给本地方法(可以理解为其他语言编写的交给Java运行的方法,使用Native关键字修饰)提供的一个专门的栈空间。在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。本地方法执行时也会创建栈帧,同样可能出现 StackOverflowError 和 OutOfMemoryError 两种错误。
- 运行时常量池: 是方法区的一部分,用于存放编译期生成的各种字面量和符号引用,具有动态性,运行时也可将新的常量放入池中。当无法申请到足够内存时,会抛出 OutOfMemoryError 异常。
- 直接内存:不属于 JVM 运行时数据区的一部分,通过 NIO 类引入,是一种堆外内存,可以显著提高 I/O 性能。直接内存的使用受到本机总内存的限制,若分配不当,可能导致 OutOfMemoryError 异常。
JVM内存模型里的堆和栈有什么区别?
理解前面内容之后,这个问题应该不难回答。
用途:栈存放的是局部变量、方法参数、方法返回值等临时数据,当一个方法被调用时,栈上就会生成一个栈帧,存储方法的相关信息,使用完毕后,自动清除栈帧。堆区存放的是对象的实例,比如new 一个对象,对象的实例就会在堆上分配空间。
生命周期:栈生命周期比较明确,当方法调用完毕后,栈帧自动销毁,其存储的临时数据自然也就被销毁。堆生命周期不明确,只有被GC(Garbage Collection)即垃圾收集器检测到不再使用才会销毁
存取速度:栈的存取速度较快,其分配和回收遵循先进后出原则,比较简单。堆存取速度较慢,分配回收需要更多时间,并且垃圾回收机制也会占用性能。
存储空间:栈的空间相对较小,且固定,由操作系统管理。当栈溢出时,通常是因为递归过深或局部变量过大。堆的空间较大,动态扩展,由JVM管理。堆溢出通常是由于创建了太多的大对象或未能及时回收不再使用的对象。
可见性:栈中的数据对线程是私有的,每个线程有自己的栈空间。堆中的数据对线程是共享的,所有线程都可以访问堆上的对象。
堆分为哪几部分呢?
新生代(Young Generation):新生代分为Eden Space和Survivor Space。在Eden Space中, 大多数新创建的对象首先存放在这里。Eden区相对较小,当Eden区满时,会触发一次Minor GC(新生代垃圾回收)。在Survivor Spaces中,通常分为两个相等大小的区域,称为S0(Survivor 0)和S1(Survivor 1)。在每次Minor GC后,存活下来的对象会被移动到其中一个Survivor空间,以继续它们的生命周期。这两个区域轮流充当对象的中转站,帮助区分短暂存活的对象和长期存活的对象。
老年代(Old Generation/Tenured Generation):存放过一次或多次Minor GC仍存活的对象会被移动到老年代。老年代中的对象生命周期较长,因此Major GC(也称为Full GC,涉及老年代的垃圾回收)发生的频率相对较低,但其执行时间通常比Minor GC长。老年代的空间通常比新生代大,以存储更多的长期存活对象。
元空间(Metaspace):从Java 8开始,永久代(Permanent Generation)被元空间取代,用于存储类的元数据信息,如类的结构信息(如字段、方法信息等)。元空间并不在Java堆中,而是使用本地内存,这解决了永久代容易出现的内存溢出问题。
大对象区(Large Object Space / Humongous Objects):在某些JVM实现中(如G1垃圾收集器),为大对象分配了专门的区域,称为大对象区或Humongous Objects区域。大对象是指需要大量连续内存空间的对象,如大数组。这类对象直接分配在老年代,以避免因频繁的年轻代晋升而导致的内存碎片化问题。
如果有个大对象,一般是放在哪个区域?
大对象一般放在老年代。
因为新生代主要存放生命周期较短并且内存较小的对象,如果将其放在新生代,容易占满新生代内存从而触发Minor GC,会导致大对象复制移动,增加开销,如果放入老年代,则能够减小新生代压力,减少GC次数。
并且大对象一般需要连续内存,而频繁的分配移动会导致出现内存碎片,后面大对象就可能因为内存不连续而失败,老年代一次为其生成一个连续的大内存,则不会导致内存碎片的问题。
程序计数器的作用是什么,为什么是私有的?
程序计数器的作用是保存程序当前执行指令的地址,类似于游戏中的存档,记录你程序当前执行的进度;那么每个程序为什么有一个私有的程序计数器就不奇怪了,如果你有多个程序并发,可以理解为你开了多个游戏档,我们都不希望一个存档影响另一个游戏档,当我们选择一个游戏档开始游戏时,能够明确的读取该档的内容并继续,即每个程序执行到的指令是不同的,当其获得cpu时间片时,要从该程序的程序计数器中的指令开始。
JVM中的常量池
静态常量池和运行时常量池
静态常量池也称为Class常量池,每个.java文件都会编译为一个.class的字节码文件,每个.class文件都有一个常量池,因为其是由.java文件编译而来的,是不能更改的,所以叫做静态常量池。
运行时常量池,其中的数据是可以改变的,并且是在运行时期使用。
只单纯记忆概念内容并不能内化为己用,详细描述其转化过程:
当你运行一个 Java 程序时,JVM(Java虚拟机)会把你编译好的 Test.class 文件加载进内存。这时候,它会对 .class 文件里的一些符号引用进行“解析”,转化为直接引用。这样,程序才能正常运行。
在运行时,这些引用会被保存在一种叫“运行时常量池”的内存结构中。这个常量池除了包含类、方法等的信息,还包含了一些字符串常量等。和“静态常量池”不同,运行时常量池的内容在程序运行期间是可以变化的。
什么是符号引用?
符号引用是编译时期的产物,保存在 .class 文件中,它是一种符号化的描述,比如:
- 某个类的全限定名(如:java/lang/String)
- 某个字段的名称和描述符(如:name:Ljava/lang/String;)
- 某个方法的签名(如:init:(Ljava/lang/String;)V)
这些东西在编译时无法确定具体地址,所以只是“名字上的引用”。
什么是直接引用?
直接引用是运行时的概念。当 JVM 把符号引用“解析”后,就知道了这个类、方法或字段在内存中的真实位置,它就变成了一个直接引用。比如:
- 指向方法区中某个类对象的指针
- 指向某个字段的偏移量
- 指向本地方法栈的地址等
这些引用是具体、可操作的地址或偏移量,能被 JVM 直接使用来访问或执行。
字符串常量池
它包含于运行时常量池之中。
其内存位置也随JDK版本不同而不同,可以联想JDK8中方法区的变化。
JDK7以及以后,字符串常量池的内存区域从永久代更换到了堆内存,也就是说字符串常量池逻辑上是运行时常量池也就是方法区的一部分,而实际内存区域是在堆空间。
JDK8以及以后,直接取消了永久代,内存结构进一步简化,把字符串池放到运行时常量池的一部分里。
什么是字符串常量池?
当你在 Java 程序中写了 String s = "hello";,这个 "hello" 不是每次都新建一个,而是放进一个“池子”里,也就是字符串常量池(String Constant Pool)。如果后面还有人写 String s2 = "hello";,Java 就会让 s2 和 s 指向同一个 "hello" 对象,节省内存。
JVM 为什么要这样设计?
因为字符串是 Java 里用得最多的一种对象,如果每个都创建新对象就会:
- 占用太多内存;
- 增加垃圾回收负担;
- 比较字符串也变慢(不能用 == 来比较地址了)。
- 所以 JVM 做了优化:
相同的字符串只存一份;
用一个共享的池子来管理;
可以使用 intern() 方法手动把字符串加入池中。
String a =“123” 和String a = new (“123”)的底层区别
写法一:String a = "123";
这是最常见也最推荐的写法,使用了字符串常量池。
特点:
- "123" 会被放入 字符串常量池。
- 如果池中已经有 "123",变量 a 会直接指向它。
- 不会新建对象,节省内存。
- 多个相同字面量的变量会引用同一个对象。
String a = "123";
String b = "123";
System.out.println(a == b); // 输出 true(引用相同)
写法二:String a = new String("123");
这是显式地通过构造函数创建新的字符串对象。
特点:
- new String("123") 会在堆内存中新建一个对象。
- "123" 这个字面量仍然会进入 字符串常量池,但 a 指向的是 新建的堆对象。
- 所以此时存在两个 "123":一个在池中,一个在堆中。
String a = new String("123");
String b = "123";
System.out.println(a == b); // 输出 false(引用不同)
System.out.println(a.equals(b)); // 输出 true(内容相同)
引用类型有哪些?有什么区别?
- 强引用指的就是代码中普遍存在的赋值方式,比如A a = new A()这种。强引用关联的对象,永远不会被GC回收。
- 软引用可以用SoftReference来描述,指的是那些有用但是不是必须要的对象。系统在发生内存溢出前会对这类引用的对象进行回收。
- 弱引用可以用WeakReference来描述,他的强度比软引用更低一点,弱引用的对象下一次GC的时候一定会被回收,而不管内存是否足够。
- 虚引用也被称作幻影引用,是最弱的引用关系,可以用PhantomReference来描述,他必须和ReferenceQueue一起使用,同样的当发生GC的时候,虚引用也会被回收。可以用虚引用来管理堆外内存。