ps:工作一年之后,又重温了一边Java虚拟机相关的内容知识点,发现之前本文挺多地方写得不太正确,所以打算重新完善一下内容:
Java语言和其他语言在内存管理的区别
对比其他语言,例如C语言,在内存管理方面,Java要做得更加“智能”一些。主要是因为Java语言提供了相关的虚拟机进行内存管理。
通常在C语言里面,创建一个对象之后需要手动进行对象内存的delete,free处理。例如这段代码:
#include <iostream> using namespace std; int main() { cout << "free begin " << endl; void* p = malloc(1024 * 1024 * 10 * sizeof(int)); free(p); cout << "free end "; } 复制代码
代码内部需要手动执行free函数。
而在Java程序中却没有这类操作对存在,关于内存对分配和释放对于开发人员来说是完全透明的,主要工作交给了Java虚拟机去完成。但是这样的设计也有弊端:一旦出现了内存泄漏排查也比较困难。
内存管理比对图
Jvm的内存管理是如何设计的?
在业界中,存在着许多款虚拟机,例如大名鼎鼎的Hotspot,j9,jrockit等,不同的虚拟机在底层的设计实现都有着较为明显的偏差,所以这里我主要介绍比较著名的Hotspot虚拟机相关的知识点。
关于HotSpot实现的jdk(1.8版本)中,在内存布局模块大致可以用这么一张图来表示:
\
内存布局–程序计数器
程序计数器其实就是当前程序所运行的字节码行号指令器。字节码指令在工作的时候通过程序计数器的值来获取下一条指令值,程序计数器的值相当于不同指令所在的内存地址。
下边我们通过一段代码来查看分析。
public class TestDemo { private int addOne(int a){ return a+1; } public static void main(String[] args) { TestDemo testDemo = new TestDemo(); int p = 1; int j = 2; int result = testDemo.addOne(p) + j; System.out.println(result); } } 复制代码
这样的一段代码,通过Javac 命令先进行编译为class文件,然后再通过Javap -c 查看字节码内容,就会得出这么一份内容:
public class org.idea.netty.framework.server.test.TestDemo { public org.idea.netty.framework.server.test.TestDemo(); Code: 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return public static void main(java.lang.String[]); Code: 0: new #2 // class org/idea/netty/framework/server/test/TestDemo 3: dup 4: invokespecial #3 // Method "<init>":()V 7: astore_1 8: iconst_1 9: istore_2 10: iconst_2 11: istore_3 12: aload_1 13: iload_2 14: invokespecial #4 // Method addOne:(I)I 17: iload_3 18: iadd 19: istore 4 21: getstatic #5 // Field java/lang/System.out:Ljava/io/PrintStream; 24: iload 4 26: invokevirtual #6 // Method java/io/PrintStream.println:(I)V 29: return } 复制代码
看到最左边的字节码序列号,在14号的位置,执行了一次addOne操作,在该函数执行之后,需要重新回到之前的调用方位置,继续执行之前剩下的操作:
int result = testDemo.addOne(p) + j; 复制代码
这个时候就需要提前使用程序计数器(PC)记录下后续需要返回的程序指令所在的内存地址。
为什么需要这一设计?
因为在操作系统中CPU是轮流切换不同的线程,所以当某个程序执行到一半,CPU去执行其他程序了,此时就需要有一个中间介质将之前执行的程序运行到的地址给记录下来,方便后续调用的时候直接提取使用。
为了最大化减少CPU来回切换过程中,对每个线程执行下一指令的影响,程序计数器被设计存放在了栈当中。
\
内存布局–栈
比较多的书籍里买呢,关于虚拟机栈总有不同的说法,这里我推荐以周志明老师的《深入理解Java虚拟机》一书中的说法为准。早期的虚拟机里面关于栈的说法主要有两大门派,分别是本地方法栈和虚拟机栈。
- 本地方法栈 当调用的是原生native方法的时候,需要寄存到本地方法栈当中
- 虚拟机栈 专门为调用jvm内部方法所提供的一个栈
但是在主流的Hotspot虚拟机中本地虚拟栈和虚拟机栈已经被融合成了一体,所以并没有过多的区别。
Java的栈是属于线程私有的一个模块,生命周期和线程一样。 每个栈里面都会存储一定的栈帧,栈帧包含了操作数栈,动态链接,局部变量表,方法出口。
操作数栈
执行指令的时候,需要将指令加入到操作数栈当中,而此时执行的每一条指令都需要压入到操作数栈里面。
更加详细的内容可以参考以下文章:
https://blog.csdn.net/qq_28666081/article/details/85269879 复制代码
动态链接
动态链接的作用主要还是提供栈里面的对象在进行实例化的时候,能够查找到堆里面相应的类地址,并进行引用。这一整个过程,我们称之为动态链接。
局部变量表
每个函数的局部变量,参数列表。这里面的作用主要是存储一系列的变量信息,而且这些变量都是以数字数组的形式来存储的,一般而言byte,short,char,类型的数据在存储的时候会变为int类型,boolean类型也是存储为数字类型,long,double则是转换为双字节大小的控件存储在栈里面。
方法出口
某个子方法执行完毕之后,需要回到主方法的原有位置继续执行程序,方法出口主要就是记录该位置地址信息。
由于栈帧是属于线程私有的内存区域,所以有的时候如果在一个私有函数中包含了过多的临时变量,或者某些函数的递归层数过深都会导致栈帧空间不足,从而报出stackoverflowerror异常。
ps:注意hotspot的虚拟机是不允许栈空间不足继续扩容的,但是早期的classic虚拟机却允许。
1.为什么栈要用来存储基本变量信息和对象引用
java虚拟机的基本架构就是采用栈来进行设计的。当一个程序需要运行的时候,由于要预先内存空间和运行的生命周期,所以需要进行指针的变动,来进行内存大小的分配。是的,由于这个操作会对程序的执行带来一定的不方便,所以一般栈被用来存放一些基本的变量类型或者引用对象的地址,而对于存储数据量较为庞大的java对象责备存储在了堆里面了。
2.为什么说栈的提取速度比堆要快?
我对于硬件部分也不是很理解,所以这里也只好从软件方面来分析,总结分析之后得出原因有以下几个:
1.栈里面的内存大小一般都是程序启动的时候由系统分配好的。
2.堆的内存大小需要在使用的时候才回去申请,而且每次对于内存大小的申请和归还都会比较消耗性能,开销较大。
3.cpu里面会有专门的寄存器来操作栈,堆里面都是使用间接寻址的方式来进行对象查找的,所以栈会快一些。
3.虚拟机栈里面为什么会有数据共享一说?
这个原因我们可以举一个例子来说吧:
假设有这么一段代码:
int j=10; int k=10; j=12; 复制代码
对于这段简单的代码而言,虚拟机里面的栈又是如何处理的呢?其实比较简单的理解就是如下所示:
首先在栈里面会先查找是否存在一个区域存放有10,如果没有就创建一个区域存放10。然后将j引用到这个变量里面去。接着当程序执行到第二句的时候,虚拟机会先到栈里面去查找一下是否存在一个值为10的区域,如果有就直接让k指向10。所以基本如下图所示:
当后期对于j变量的值进行修改为12以后,栈就会重复性的先去查找是否有12,如果没有开辟新的空间用于存储12,然后让j指向这个值
这里面的10这个值,被k,j共同引用的过程就叫做数据共享。
\
内存布局–堆
在JVM中的堆区域,这是属于一个公共部分的区域,算是jvm里面的最大的一块内存区域了,几乎所有的对象都是存储在堆这个模块中,(也有特殊情况会将对象分配到栈上)堆设计的初衷其实就是为了给对象存储所使用的。
网上经常会有一些文章或者传言说,堆分为了年轻代,老年代,年轻代又分为eden区,survivor区域。其实这种说法是不严谨的描述,因为大部分程序员采用的jdk都是hotspot的相关产品,该类虚拟机在早期的时候主要是采用了分代回收的思路来进行内存管理,而如今虚拟机早已提供了更多优秀的垃圾收集技术,所以这种说法准确来讲应该换成:采用分代回收思路来进行收集的主流java虚拟机中,堆主要分为了年轻代,老年代…
关于对象存储的位置,其实只能说大部分存储在堆中,但是少数情况下,堆可以额外开辟一个空间用于给线程存储一些属于它们专有的buffer。这种技术叫做TLAB,属于栈上分配技术。
为什么要发明TLAB(ThreadLocalAllocBuffer )技术?
学习一门技术的过程中,弄清楚其发展的原因其实是非常重要的,不然很容易就变得知其然而不知其所以然,知道有这么一种技术,知道该怎么熟练运用,其背后的原理,但是却不了解为什么要这么设计。
首先我们来思考一下这么一个问题,当一个全新的对象需要分配内存的时候需要考虑哪些情况?
- 计算对象所需要的空间大小
- 寻找合适的内存区域
- 将对象分配到指定的内存区域
分配对象的过程主要包含了上述的这几个步骤,那么假设在多线程的环境下,情况就变得复杂了。分配对象的过程中还需要考虑加锁控制,内存空间中的一些指针碰撞问题等等问题,因此通过堆来分配内存其实还是一件非常繁琐的事情。在早期jdk1.5出来之后,java语言的市场日渐庞大,企业级的大型系统应用开始渐渐增多,于是在hotspot jvm 1.6 推出的时候,出现了TLAB技术,专门用于优化这种堆频繁分配内存造成的性能损耗问题。
\
内存布局–方法区
在jvm里面,还有一个公共的内存区域被叫做为方法区,主要是用于存储一些常量池的数据信息和jvm初始化过程中加载的类文件信息。
很多时候我们都容易产生一个知识误区,误以为永久代就是方法区,但是这种说法是不完善的,需要有所调整,因为大部分的时候我们都是使用了Hotspot虚拟机,而其他的例如说j9,jrockit虚拟机,它们并没有永久代这么一个说法。
在早期的时候,Hotspot虚拟机采用了分代收集的思路来进行垃圾回收,才会将方法区这个部分归拢为了永久代(full gc的时候是会回收的),但是当后续演进过程中,Hotspot团队发现使用永久代在垃圾回收的时候并不高效率,甚至在jdk8的时候将其进行了废弃。
ps :方法区是和堆属于两个不同的存储区域,永久代是属于Hotspot系列专有的一种说法。
ps :方法区也是会进行垃圾回收的,只不过这里回收效果的性价比较低。
方法区和堆很相似,里面存放的信息也是线程共享的,它包含的信息整理成脑图之后基本如下所示:
方法区中包含的都是在整个程序中永远唯一的元素,如class,static变量。
- 方法区又被称为静态区,和堆有着类似的共性,也是各个线程共享的内存区域。它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样 不需要连续的内存和可以选择固定大小或者可扩展之外,还可以选择不实现垃圾回收。
这区域的内存回收目标主要是针对常量池的回收和类型的卸载,一般而言,这个区域的内存回收比较难以令人满意,尤其是类型的回收,条件相当苛刻,但是这部分区域的内存回收确实是必要的。
- 很多开发者更愿意把方法区称为“永久代”(Perm Gen)(Permanent Generation)「总是存放不会轻易改变的内容」。在目前已经发布的JDK 1.7 的HotSpot中,已经把原本放在永久代的字符串常量池移至堆中。
- 运行时常量池(Runtime Constant Pool)是方法区的一部分。
JDK 1.8 中,已经没有方法区(永久代),而是将方法区直接放在一个与堆不相连的本地内存区域(Native Memory),这个区域被叫做元空间。
JVM的内存布局–直接内存
直接内存的这个模块其实并不是java虚拟机规范所定义的内存区域,但是在使用的时候,因为这块内存是使用了操作系统中的内存空间,所以如果使用中超过了机器限制的内存大小,也会有oom发生。
在jdk1.4的时候出现了nio技术,这里面引入了channel和buffer的概念,比较经典的代表就是使用directbytebuffer对象来作为直接内存引用的相关操作,从而避免了java堆和native堆的来回复制数据问题。