目录
一、JAVA内存区域与内存溢出异常
1. 概述
对于Java程序员来说,在虚拟机自动内存管理机制下,不再需要像C/C++程序开发程序员这样为内一个new 操作去写对应的delete/free操作,不容易出现内存泄漏和内存溢出问题。正是因为Java程序员把内存控制权利交给Java虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。PS:《深入理解JAVA虚拟机》这本书的第一章介绍的是一些JAVA的历史以及JDK开发的知识,本人还是小白,对此不做详细讲解。
2. 运行时数据区域
Java虚拟机在执行Java程序的过程中会把它管理的内存划分成若干个不同的数据区域。
我之前学习的时候自己画了一张简单的JAVA内存图,方便理解(点击放大)
2.1 程序计数器
冯 ·诺伊曼计算机体系结构的主要内容之一就是“程序预存储,计算机自动执行”!
冯.诺依曼体系结构是现代计算机的基础,现在大多计算机仍是冯.诺依曼计算机的组织结构,只是作了一些改进而已,并没有从根本上突破冯体系结构的束缚。冯.诺依曼也因此被人们称为“计算机之父”。然而由于传统冯.天然所具有的局限性,从根本上限制了计算机的发展。
根据冯·诺依曼体系结构构成的计算机,必须具有如下功能:把需要的程序和数据送至计算机中。必须具有长期记忆程序、数据、中间结果及最终运算结果的能力。能够完成各种算术、逻辑运算和数据传送等数据加工处理的能力。能够根据需要控制程序走向,并能根据指令控制机器的各部件协调操作。能够按照要求将处理结果输出给用户。
将指令和数据同时存放在存储器中,是冯·诺依曼计算机方案的特点之一
计算机由控制器、运算器、存储器、输入设备、输出设备五部分组成
冯·诺依曼提出的计算机体系结构,奠定了现代计算机的结构理念。
处理器要执行的程序(指令序列)都是以二进制代码序列方式预存储在计算机的存储器中,处理器将这些代码逐条地取到处理器中再译码、执行,以完成整个程序的执行。
为了保证程序能够连续地执行下去,CPU必须具有某些手段来确定下一条取指指令的地址。
程序运行过程:
程序—机器语言的EXE文件—内存(EXE文件的副本)—CPU解释并执行程序内容
程序计数器(PC )正是起到这种作用,所以通常又称之为‘指令计数器’。CPU总是按照PC的指向对指令序列进行取指、译码和执行,也就是说,最终是PC 决定了程序运行流向。故而,程序计数器(PC )属于特别功能寄存器范畴,不能自由地用于存储其他运算数据。
工作流程:
1、在程序开始执行前,将程序指令序列的起始地址,即程序的第一条指令所在的内存单元地址送入PC,
2、CPU按照PC的指示从内存读取第一条指令(取指)。
3、当执行指令时,CPU自动地修改PC的内容,即每执行一条指令PC增加一个量,这个量等于指令所含的字节数(指令字节数),使PC总是指向下一条将要取指的指令地址。
4、由于大多数指令都是按顺序来执行的,所以修改PC的过程通常只是简单的对PC 加“指令字节数”。
5、当程序转移时,转移指令执行的最终结果就是要改变PC的值,此PC值就是转去的目标地址。
6、处理器总是按照PC指向,取指、译码、执行,以此实现了程序转移。
如图所示,程序计数器是一块较小的内存空间,大小几乎可以忽略不计,可以看作是当前线程所执行的字节码的行号指示器,仅储存地址信息。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完。
线程是CPU 最小的调度单元,Java 虚拟机的多线程是通过切换线程并分配处理器执行时间的方式来实现的,在任何一个确定的时间,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是native方法,这个计数器值则为空(Undefinded)。
程序计数器是唯一个在java虚拟机规范中没有规定任何OutOfMemoryError情况的区域(OOM)
2.2 Java虚拟机栈
虚拟机栈同程序计数器一样,都是线程私有的,生命周期跟线程相同。
虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈幁,用来存储局部
变量表,操作栈,动态链接,方法出口等信息。每个方法从调用直到执行完成的过程,都对应一个栈幁在虚
拟机栈中从入栈到出栈的过程。一般将栈帧内存的大小称为宽度,而栈帧的数量被称为虚拟机栈的深度。虚拟机栈的大小可以通过参数-xss配置。因此在同等大小的虚拟机栈下,如果局部变量表等占用的内存越小,虚拟机栈的深度越大。
在编译程序代码的时候,栈帧需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的code属性中,因此一个栈帧需要分配多少内存,不会受到运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。一个线程中的方法调用链路可能会很长,很多方法都处于同时执行的状态。对于执行引擎来说,在活动线程中,只有处于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。
执行引擎运行的所有字节码指令只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图所示:
在java虚拟机规范中,对这个区域规定了2中异常状态:
如果线程请求的深度大于虚拟机允许的深度,将抛出StackOverflowError异常;如果虚拟机可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出一个OOM异常。(虽然当前大部分java虚拟机都支持动态扩展,但java虚拟机规范中也允许固定长度的虚拟机栈)
1) 局部变量表
局部变量表是一组变量值存储空间,用以存储方法参数与方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,虚拟机规范中并没有明确指出一个slot占应用内存的大小,只是很有导向性的指出一个slot都应该可以存放一个byte、short、int、float、char、boolean、对象引用(reference)、returnAddress(指向一个字节码指令的地址),这8种类型的数据,都可以使用32位或者更小的空间去存储,但这种描述与明确指出“每个slot占用32位的内存空间”有一些区别,它允许slot的长度可以随着处理器、虚拟机、操作系统的不同而发生变化。只要保证即使在64位虚拟机下使用64位内存去实现slot,虚拟机仍需要使用对齐和补白的方式使之在外观上看起来和32位下一致。
对于64位的数据类型,虚拟机会通过高位补齐的方式为其分配两个连续的slot空间,java中明确的64位的数据类型只有long、double,(reference类型可能是32,也可能是64位的),值得一提的是,这里把long、double分割存储的做法与”long和double的飞原子性协定”把一次long和double的读写分割为两次32位的读写做法有些类型。不过,由于局部变量表在虚拟机栈中,是线程私有的数据,所以无论读写两个连续的slot是否是原子性操作,都不会出现线程安全的问题。
虚拟机通过索引定位的方式定位局部变量表,索引的范围从0开始到局部变量表最大的slot数量。如果访问的是32位数据类型,索引n就代表使用了第n个slot;如果访问的是64位数据类型,索引n就代表使用了第n和n+1个slot。对于两个相邻的存放64位数据的slot,不能单独访问其中一个,java虚拟机规范中明确要求了如果遇到了这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常。
在执行方法的时候,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static),那局部变量表的第0个slot默认用来传递方法所属对象的引用,在方法中通过this关键字可以访问这个隐含的参数。其余参数按照参数表顺序排列,参数表分配完毕,再根据方法内部局部变量的顺序和作用域分配slot。
为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。不过,这样的设计除了节省栈帧空间以外,还会伴随一些额外的副作用,例如,在某些情况下,Slot的复用会直接影响到JVM的垃圾收集行为,下为示例代码:
{
//回收方法1
public void localvarGc1() {
byte[] a = new byte[6*1024*1024];
System.gc();
}
//回收方法2
public void localvarGc2() {
byte[] a = new byte[6*1024*1024];
a=null;
System.gc();
}
//回收方法3
public void localvarGc3(){
{
byte[] a = new byte[6*1024*1024];
}
System.gc();
}
//回收方法4
public void localvarGc4() {
{
byte[] a = new byte[6*1024*1024];
}
int c = 10;
System.gc();
}
//回收方法5
public void localvarGc5() {
localvarGc1();
System.gc();
}
public static void main(String[] args) {
GcTest ins = new GcTest();
ins.localvarGc1();
}
}
在上述代码中,每一个localvarGcN()函数都分配了一块6MB的堆空间,并是用局部变量引用这块空间。
在localvarGc1()中,在申请空间后,立即进行垃圾回收,很明显,由于byte数组被变量a引用,因此无法回收这块空间。
在localvarGc2()中,在垃圾回收前,先将变量a置为null,使byte数组失去强引用,故垃圾回收可以顺利回收byte数组。
对于localvarGc3(),在进行垃圾回收前,先使局部变量a失效,虽然变量a已经离开了作用域,但是变量a依然存在于局部变量表中,并且也指向这块byte数组,故byte数组依然无法被回收。
对于localvarGc4(),在垃圾回收之前,不仅使变量a失效,更是申明了变量c,使变量c复用了变量a之前的栈,由于变量a此时被销毁,姑垃圾回收器可以顺利回收byte数组。
对于localvarGc5(),它首先调用了localvarGc1(),很明显,在localvarGc1()中并没有释放byte数组,但在localvarGc1()返回后,它的栈帧被销毁,自然也包含了栈帧中的所有局部变量,故byte数组失去引用,在localvarGc5()的垃圾回收中被回收。
在启动jvm虚拟机的时候可以使用参数-XX:+PrintGC执行上述几个函数,在输出的日志中,可以看到垃圾回收前后堆的大小,进而推断byte数组是否被回收。
下面的输出是函数localvarGc4()回收成功的运行结果:
从日志中可以看到,堆空间从回收前的6809k变为回收后的600k,释放了约6MB空间。进而可以推断,byte数组已被回收释放。
下面是函数localvarGc1()的回收失败运行结果:
可以看出堆空间几乎没有变化。
由此我们可以得知,如果遇到一个方法,后面的操作用时很长,并且很占内存,而前面已经占去了那么多内存又不会去使用,可以手动设置null。这种操作可以作为一种在极特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到JIT的编译条件)下的“奇技”来使用。
关于局部变量表,还有一点要注意,可能会影响开发的,就是他不存在类变量和实例变量那样的准备阶段,不存在初始值,在使用之前,必须要给值。在使用前,不给值,这段代码其实并不能运行,还好编译器能在编译期间就检查到并提示这一点,即使编译能通过或者手动生成字节码的方法制造出下面代码的效果,字节码校验的时候也会被虚拟机发现而导致类加载失败。
2)操作数栈
Java虚拟机的解释执行引擎被称为"基于栈的执行引擎",其中所指的栈就是指-操作数栈。
操作数栈也常被称为操作栈,它是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也是编译的时候被写入到方法表的Code属性的max_stacks数据项中。但是和前者不同的是,它不是通过索引来访问,而是通过标准的栈操作—压栈和出栈—来访问的。比如,如果某个指令把一个值压入到操作数栈中,稍后另一个指令就可以弹出这个值来使用。
虚拟机在操作数栈中存储数据的方式和在局部变量区中是一样的:如int、long、float、double、reference和returnType的存储。对于byte、short以及char类型的值在压入到操作数栈之前,也会被转换为int。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。栈容量的单位为“字宽”,对于32位虚拟机来说,一个”字宽“占4个字节,对于64位虚拟机来说,一个”字宽“占8个字节。
当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指向操作数栈中写入和提取值,也就是入栈与出栈操作。例如,在做算术运算的时候就是通过操作数栈来进行的,又或者调用其它方法的时候是通过操作数栈来行参数传递的。
另外,在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但是大多数虚拟机的实现里都会作一些优化处理,令两个栈帧出现一部分重叠。让下栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用返回时就可以共用一部分数据,而无须进行额外的参数复制传递了,重叠过程如下图:
虚拟机把操作数栈作为它的工作区——大多数指令都要从这里弹出数据,执行运算,然后把结果压回操作数栈。比如,iadd指令就要从操作数栈中弹出两个整数,执行加法运算,其结果又压回到操作数栈中,看看下面的示例,它演示了虚拟机是如何把两个int类型的局部变量相加,再把结果保存到第三个局部变量的:
begin
iload_0 // push the int in local variable 0 onto the stack
iload_1 // push the int in local variable 1 onto the stack
iadd // pop two ints, add them, push result
istore_2 // pop int, store into local variable 2
end
在这个字节码序列里,前两个指令iload_0和iload_1将存储在局部变量中索引为0和1的整数压入操作数栈中,其后iadd指令从操作数栈中弹出那两个整数相加,再将结果压入操作数栈。第四条指令istore_2则从操作数栈中弹出结果,并把它存储到局部变量区索引为2的位置。下图详细表述了这个过程中局部变量和操作数栈的状态变化,图中没有使用的局部变量区和操作数栈区域以空白表示。
3)动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。(博主对这块不太了解,以后再作补充)
4)方法返回地址
当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法方式称为正常完成出口(Normal Method Invocation Completion)。
另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的调用都产生任何返回值的。
无论采用何种方式退出,在方法退出之前,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用都栈帧的操作数栈中,调用PC计数器的值以指向方法调用指令后面的一条指令等。
5) 附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与高度相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址与其它附加信息全部归为一类,称为栈帧信息。
2.3本地方法栈
本地方法栈与虚拟机栈发挥的功能非常类似
只是虚拟机栈为虚拟机执行java方法而服务,而本地方法栈为虚拟机执行native方法而服务。
native方法主要用于加载文件和动态链接库,由于Java语言无法访问操作系统底层信息(比如:底层硬件设备等),这时候就需要借助C语言来完成了。被native修饰的方法可以被C语言重写。(关于native关键字由于时间原因排到以后再来解析)
虚拟机规范中对本地方法栈中使用的方法的语言、使用方式、与数据结构并没有强制规定。因此具体的虚拟机可以有各自的实现方式。
甚至有的虚拟机把本地方法栈和虚拟机栈合二为一,比如Sun HotSpot 虚拟机。
与虚拟机栈一样,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。
对于一个运行中的java程序而言,可能会用到跟本地方法相关的数据区域。当一个线程调用本地方法时,它就进入一个全新的不受虚拟机限制的全新世界。本地方法也可以通过本地方法接口调用虚拟机的运行时数据区域。
本地方法本质上依赖于实现,虚拟机的实现者可以自由得决定通过什么机制让java程序调用本地方法。
任何本地方法接口都会使用某种本地方法栈。当虚拟机调用java方法时,虚拟机会创建一个栈帧并且压入虚拟机栈;当虚拟机调用本地(native)方法时,虚拟机不会创建新的栈帧,虚拟机栈会保持不变,虚拟机只是简单的动态连接并直接调用相关的本地方法。
如果本地方法接口是c连接模型的话,它的本地方法栈就是c栈。当c程序调用一个c函数时,传递给该函数的参数以相应的顺序压入栈,它的返回值以确定的方式返回给调用方。这就是虚拟机实现中本地方法栈的行为。
也有一种情况就是本地方法需要调用java方法,这时候本地方法栈会保存状态并进入另一个java栈。
下图描绘的场景是:一个线程调用本地方法,本地方法又需要调用java方法的情况。
这幅图展示了在java虚拟机内部线程执行的情况,可能一直在执行java方法,操作java栈;也可能在java栈和本地方法栈中来回切换。
该线程首先调用了两个java方法,然后第二个java方法调用了本地方法。假设这是一个c栈,第一个c函数有调用了第二个c函数,第二个函数又调用了一个java方法,进入java栈,然后这个java方法又调用一个java方法,即当前栈帧对应的方法,当前方法。
与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError异常和OOM异常。
2.4 堆
Java虚拟机所管理的内存中最大的一块,Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。Java堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:在细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。
同样,我也画了张图用来介绍GC回收机制(点击放大)
根据java虚拟机的规定,java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是扩展的,不过当前主流都是按可扩展来实现(通过-Xmx和-Xms控制)。如果在堆中有内存没有完成实例分配,并且无法扩展时,将会抛出OOM异常。
2.5 方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
HotSpot虚拟机中方法区也常被称为 “永久代”,本质上两者并不等价。仅仅是因为HotSpot虚拟机设计团队用永久代来实现方法区而已,这样HotSpot虚拟机的垃圾收集器就可以像管理Java堆一样管理这部分内存了。但是这并不是一个好主意,因为这样更容易遇到内存溢出问题。 相对而言,垃圾收集行为在这个区域是比较出现的,但并非数据进入方法区后就“永久存在”了。
JDK1.8以后废除了永生代,使用了元空间。元空间是方法区的在HotSpot jvm 中的实现,方法区主要用于存储类的信息、常量池、方法数据、方法代码等。方法区逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫“非堆”。
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。理论上取决于32位/64位系统可虚拟的内存大小。可见也不是无限制的,需要配置参数。
同样,方法区也会抛出OOM异常。
2.6 运行时常量池
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)
同样,方法区也会抛出OOM异常。
2.7直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致OOM异常出现。
JDK1.4中新加入的NIO(New Input/Output)类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的I/O方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在Java堆和Native堆之间来回复制数据。
本机直接内存的分配不会收到Java堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。
3.HotSpot虚拟机的对象探秘
介绍完java虚拟机的运行时数据区之后,我们大致知道了虚拟机内存的概况。读者了解了内存中放了什么之后,也许就会想更进一步了解这些虚拟机内存中的数据的其他细节,譬如他们是如何创建、布局和访问的。对于这样涉及细节的问题,必须把讨论范围限定在具体的虚拟机和集中在某一内存区域上才有意义。基于实现优先原则,笔者以常用的虚拟机HotSpot何常用的内存区域Java堆为例,深入探讨HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程。
3.1HotSpot虚拟机简介
提起HotSpot VM,相信所有Java程序员都知道,它是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的Java虚拟机。但不一定所有人都知道的是,这个目前看起来“血统纯正”的虚拟机在最初并非由Sun公司开发,而是由一家名为“Longview Technologies”的小公司设计的;甚至这个虚拟机最初并非是为Java语言而开发的,它来源于Strongtalk VM,
而这款虚拟机中相当多的技术又是来源于一款支持Self语言实现“达到C语言50%以上的执行效率”的目标而设计的虚拟机,
Sun公司注意到了这款虚拟机在JIT编译上有许多优秀的理念和实际效果,在1997年收购了Longview Technologies公司,从而获得了HotSpot VM。
3.2 对象的创建
虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
指针碰撞(Bump the Pointer)
如果Java堆中的内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所谓分配内存就是把指针向空闲区域挪动一段与对象大小相等的距离,这种分配方式就叫做指针碰撞。
空闲列表(Free List)
如果Java堆中的内存并不是规整的,已使用的和空闲的内存互相交错,那就没有办法进行简单的指针碰撞了。虚拟机必须维护一个列表,上面记录了那些内存块是可用的,在分配内存的时候从列表中找到一块足够大的内存空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表。
线程安全
由于创建对象在虚拟机中是非常频繁的行为,即使仅仅修改一个指针位置,在并发的情况下也不是线程安全的,可能出现正在给A对象分配内存,指针还没来得及修改,B对象又用原来的指针来分配内存的情况。实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性,另一种方法的话是使用本地线程分配缓冲(Thread Local Allocation Buffer)。这种方法把内存的分配按照线程划分在不同空间进行,内存分配在各自的TLAB上实现,只有TLAB用完需要分配新的TLAB时才需要同步锁定。虚拟机是否使用TLAB可以通过-XX:+/-UseTLAB参数来设定。
内存分配完毕后,虚拟机需要将分配到的内存空间都置为零值(不包括对象头)。如果使用了TALB,这一工作过程可以提前至TALB分配时进行。这一步操作保证了对象的实例字段(成员变量)在Java可以不赋值就直接使用,程序能访问字段类型对应的零值。
接下来,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的GC分代年龄等信息。这些信息存放在对象头中,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会与不同的设置方式。 new指令执行完后,再按照程序员的意愿执行init方法后一个真正可用的对象才诞生。
3.3 对象的内存布局
在Hotspot虚拟机中,对象在内存中的布局可以分为3快区域:对象头、实例数据和对齐填充。
Hotspot虚拟机的对象头(Mark Word)包括两部分信息,第一部分用于存储对象自身的自身运行时数据(哈希吗、GC分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。这部分的储存顺序会受到虚拟机分配策略(FieldsAllocationStyle)和字段在源码中定义顺序的影响,例如在Hotspot中就会把相同宽度的字段分配在一起,父类变量会在子类变量之前,并且可以通过CompactFields参数将子类的中较窄的变迁插到父类变量的空隙中。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
3.4 对象的访问定位
建立对象就是为了使用对象,我们的Java程序通过栈上的reference数据来操作堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有①使用句柄和②直接指针两种:
1) 如果使用句柄的话,那么Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据(new对象的信息)与对象类型数据(Class信息)各自的具体地址信息;
2)如果使用直接指针访问,那么Java堆对像的布局中就必须考虑如何防止访问类型数据的相关信息,reference中存储的直接就是对象的地址。
这两种对象访问方式各有优势。
使用句柄来访问的最大好处是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。
使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
4.OOM异常
在java虚拟机中,除了程序计数外,内存的其他区域运行时都有可能发生OOM异常(OutOfMemoryError)
我的职业生涯中见过数以千计的内存溢出异常均与下文中的8种情况相关。本文分析什么情况会导致这些异常出现,提供示例代码的同时为您提供解决指南。
4.1 Java堆空间不足
J
ava.lang.OutOfMemoryError:Java heap space
Java应用程序在启动时会指定所需要的内存大小
它被分割成两个不同的区域:Heap space(堆空间)
和Permgen(永久代/元空间)
这两个区域的大小可以在JVM(Java虚拟机)启动时通过参数-Xmx
和-XX:MaxPermSize
设置,如果你没有显式设置,则将使用特定平台的默认值。
当应用程序试图向堆空间添加更多的数据,但堆却没有足够的空间来容纳这些数据时,将会触发java.lang.OutOfMemoryError: Java heap space
异常。需要注意的是:即使有足够的物理内存可用,只要达到堆空间设置的大小限制,此异常仍然会被触发。
原因分析
触发java.lang.OutOfMemoryError: Java heap space
最常见的原因就是应用程序需要的堆空间是XXL号的,但是JVM提供的却是S号。解决方法也很简单,提供更大的堆空间即可。除了前面的因素还有更复杂的成因:
- 流量/数据量峰值:应用程序在设计之初均有用户量和数据量的限制,某一时刻,当用户数量或数据量突然达到一个峰值,并且这个峰值已经超过了设计之初预期的阈值,那么以前正常的功能将会停止,并触发
java.lang.OutOfMemoryError: Java heap space
异常。 - 内存泄漏:特定的编程错误会导致你的应用程序不停的消耗更多的内存,每次使用有内存泄漏风险的功能就会留下一些不能被回收的对象到堆空间中,随着时间的推移,泄漏的对象会消耗所有的堆空间,最终触发
java.lang.OutOfMemoryError: Java heap space
异常。
解决方案
第一个解决方案是显而易见的,你应该确保有足够的堆空间来正常运行你的应用程序,在JVM的启动配置中增加如下配置:
-Xmx1024m
上面的配置分配1024M堆空间给你的应用程序,当然你也可以使用其他单位,比如用G表示GB,K表示KB。下面的示例都表示最大堆空间为1GB:
java -Xmx1073741824 com.mycompany.MyClass
java -Xmx1048576k com.mycompany.MyClass
java -Xmx1024m com.mycompany.MyClass
java -Xmx1g com.mycompany.MyClass
然后,更多的时候,单纯地增加堆空间不能解决所有的问题。如果你的程序存在内存泄漏,一味的增加堆空间也只是推迟java.lang.OutOfMemoryError: Java heap space
错误出现的时间而已,并未解决这个隐患。除此之外,垃圾收集器在GC时,应用程序会停止运行直到GC完成,而增加堆空间也会导致GC时间延长,进而影响程序的吞吐量。
如果你想完全解决这个问题,那就好好提升自己的编程技能吧,当然运用好Debuggers, profilers, heap dump analyzers
等工具,可以让你的程序最大程度的避免内存泄漏问题。
4.2 超出GC开销限制
java.lang.OutOfMemoryError:GC overhead limit exceeded
Java运行时环境(JRE
)包含一个内置的垃圾回收进程,而在许多其他的编程语言中,开发者需要手动分配和释放内存。
Java应用程序只需要开发者分配内存,每当在内存中特定的空间不再使用时,一个单独的垃圾收集进程会清空这些内存空间。垃圾收集器怎样检测内存中的某些空间不再使用已经超出本文的范围,但你只需要相信GC可以做好这些工作即可。
默认情况下,当应用程序花费超过98%的时间用来做GC并且回收了不到2%的堆内存时,会抛出java.lang.OutOfMemoryError:GC overhead limit exceeded
错误。具体的表现就是你的应用几乎耗尽所有可用内存,并且GC多次均未能清理干净。
原因分析
java.lang.OutOfMemoryError:GC overhead limit exceeded
错误是一个信号,示意你的应用程序在垃圾收集上花费了太多时间但却没有什么卵用。默认超过98%的时间用来做GC却回收了不到2%的内存时将会抛出此错误。那如果没有此限制会发生什么呢?GC进程将被重启,100%的CPU将用于GC,而没有CPU资源用于其他正常的工作。如果一个工作本来只需要几毫秒即可完成,现在却需要几分钟才能完成,我想这种结果谁都没有办法接受。
所以java.lang.OutOfMemoryError:GC overhead limit exceeded
也可以看做是一个fail-fast(快速失败)
实战的实例。
解决方案
首先是一个毫无诚意的解决方案,如果你仅仅是不想看到java.lang.OutOfMemoryError:GC overhead limit exceeded
的错误信息,可以在应用程序启动时添加如下JVM参数:
-XX:-UseGCOverheadLimit
但是强烈建议不要使用这个选项,因为这样并没有解决任何问题,只是推迟了错误出现的时间,错误信息也变成了我们更熟悉的java.lang.OutOfMemoryError: Java heap space
堆内存不足
而已。
另一个解决方案,如果你的应用程序确实内存不足,增加堆内存会解决GC overhead limit
问题,就如下面这样,给你的应用程序1G的堆内存:
java -Xmx1024m com.yourcompany.YourClass
但如果你想确保你已经解决了潜在的问题,而不是掩盖java.lang.OutOfMemoryError: GC overhead limit exceeded
错误,那么你不应该仅止步于此。你要记得还有profilers
和memory dump analyzers
这些工具,你需要花费更多的时间和精力来查找问题。还有一点需要注意,这些工具在Java运行时有显著的开销,因此不建议在生产环境中使用。
4.3 Permgen永生代 内存耗尽
java.lang.OutOfMemoryError:Permgen space
Java中堆空间是JVM管理的最大一块内存空间,可以在JVM启动时指定堆空间的大小,
其中堆被划分成两个不同的区域:新生代(Young)和老年代(Tenured),
新生代又被划分为3个区域:Eden(伊甸园)
、From Survivor(前幸存区)
、To Survivor(幸存区)
,如下图所示。
所有这些区域的大小,包括permgen区域,都是在JVM发布期间设置的。如果您未自行设置大小,则将使用特定于平台的默认值。
java.lang.OutOfMemoryError: PermGen space
错误就表明permgen所在区域的内存已被耗尽。
原因分析
要理解java.lang.OutOfMemoryError: PermGen space
出现的原因,首先需要理解Permanent Generation Space
的用处是什么。持久代主要存储的是每个类的Class和Meta信息,比如:类加载器引用、运行时常量池(所有常量、字段引用、方法引用、属性)、字段(Field)数据、方法(Method)数据、方法代码、方法字节码等等。我们可以推断出,PermGen
的大小取决于被加载类的数量以及类的大小。
因此,我们可以得出出现java.lang.OutOfMemoryError: PermGen space
错误的原因是:太多的类或者太大的类被加载到permanent generation
(持久代)。
Class在被Loader时就会被放到PermGen space中, 它和存放类实例(Instance)的Heap区域不同,GC(Garbage Collection)不会在主程序运行期对PermGen space进行清理,所以如果你的应用中有很多CLASS的话,就很可能出现PermGen space错误, 这种错误常见在web服务器对JSP进行pre compile的时候。如果你的WEB APP下都用了大量的第三方jar, 其大小超过了jvm默认的大小(4M)那么就会产生此错误信息了。
解决方案
① 解决初始化时的OutOfMemoryError
当在应用程序启动期间触发由于PermGen
耗尽引起的OutOfMemoryError
时,解决方案很简单。 应用程序需要更多的空间来加载所有的类到PermGen
区域,所以我们只需要增加它的大小。 为此,请更改应用程序启动配置,并添加(或增加,如果存在)-XX:MaxPermSize参数,类似于以下示例:
java -XX:MaxPermSize=512m com.yourcompany.YourClass
② 解决Redeploy
时的OutOfMemoryError
分析dump文件:首先,找出引用在哪里被持有;其次,给你的web应用程序添加一个关闭的hook,或者在应用程序卸载后移除引用。你可以使用如下命令导出dump文件:
jmap -dump:format=b,file=dump.hprof <process-id>
如果是你自己代码的问题请及时修改,如果是第三方库,请试着搜索一下是否存在"关闭"接口,如果没有给开发者提交一个bug或者issue吧。
③ 解决运行时OutOfMemoryError
首先你需要检查是否允许GC从PermGen
卸载类,JVM的标准配置相当保守,只要类一创建,即使已经没有实例引用它们,其仍将保留在内存中,特别是当应用程序需要动态创建大量的类但其生命周期并不长时,允许JVM卸载类对应用大有助益,你可以通过在启动脚本中添加以下配置参数来实现:
-XX:+CMSClassUnloadingEnabled
默认情况下,这个配置是未启用的,如果你启用它,GC将扫描PermGen
区并清理已经不再使用的类。但请注意,这个配置只在UseConcMarkSweepGC
的情况下生效,如果你使用其他GC算法,比如:ParallelGC
或者Serial GC
时,这个配置无效。所以使用以上配置时,请配合:
-XX:+UseConcMarkSweepGC
如果你已经确保JVM可以卸载类,但是仍然出现内存溢出问题,那么你应该继续分析dump文件,使用以下命令生成dump文件:
jmap -dump:file=dump.hprof,format=b <process-id>
当你拿到生成的堆转储文件,并利用像Eclipse Memory Analyzer Toolkit这样的工具来寻找应该卸载却没被卸载的类加载器,然后对该类加载器加载的类进行排查,找到可疑对象,分析使用或者生成这些类的代码,查找产生问题的根源并解决它。
4.4 Metaspace元空间 内存耗尽
java.lang.OutOfMemoryError:Metaspace
前文已经提过,PermGen永生代
区域用于存储类的名称和字段,类的方法,方法的字节码,常量池,JIT优化等,但从Java8开始,Java中的内存模型发生了重大变化:引入了称为Metaspace元空间
的新内存区域,而删除了PermGen
区域。请注意:不是简单的将PermGen
区所存储的内容直接移到Metaspace
区,PermGen
区中的某些部分,已经移动到了普通堆里面。
原因分析
Java8做出如此改变的原因包括但不限于:
- 应用程序所需要的
PermGen
区大小很难预测,设置太小会触发PermGen OutOfMemoryError
错误,过度设置导致资源浪费。 - 提升GC性能,在HotSpot中的每个垃圾收集器需要专门的代码来处理存储在
PermGen
中的类的元数据信息。从PermGen
分离类的元数据信息到Metaspace
,由于Metaspace
的分配具有和Java Heap
相同的地址空间,因此Metaspace
和Java Heap
可以无缝的管理,而且简化了FullGC
的过程,以至将来可以并行的对元数据信息进行垃圾收集,而没有GC暂停。 - 支持进一步优化,比如:G1并发类的卸载,也算为将来做准备吧
正如你所看到的,元空间大小的要求取决于加载的类的数量以及这种类声明的大小。 所以很容易看到java.lang.OutOfMemoryError: Metaspace
主要原因:太多的类或太大的类加载到元空间。
解决方案
第一个解决方案,既然应用程序会耗尽内存中的Metaspace
区空间,那么应该增加其大小,更改启动配置增加如下参数:
// 告诉JVM:Metaspace允许增长到512,然后才能抛出异常
-XX:MaxMetaspaceSize = 512m
另一个方法就是删除此参数来完全解除对Metaspace
大小的限制(默认是没有限制的)。默认情况下,对于64位服务器端JVM,MetaspaceSize默认大小是21M(初始限制值),一旦达到这个限制值,FullGC将被触发进行类卸载,并且这个限制值将会被重置,新的限制值依赖于Metaspace
的剩余容量。如果没有足够空间被释放,这个限制值将会上升,反之亦然。在技术上Metaspace
的尺寸可以增长到交换空间,而这个时候本地内存分配将会失败。
你可以通过修改各种启动参数来“快速修复”这些内存溢出错误,但你需要正确区分你是否只是推迟或者隐藏了java.lang.OutOfMemoryError
的症状。如果你的应用程序确实存在内存泄漏或者本来就加载了一些不合理的类,那么所有这些配置都只是推迟问题出现的时间而已,实际也不会改善任何东西。
4.5 无法创建新的本地线程
java.lang.OutOfMemoryError:Unable to create new native thread
一个思考线程的方法是将线程看着是执行任务的工人,如果你只有一个工人,那么他同时只能执行一项任务,但如果你有十几个工人,就可以同时完成你几个任务。就像这些工人都在物理世界,JVM中的线程完成自己的工作也是需要一些空间的,当有足够多的线程却没有那么多的空间时就会像这样:
出现java.lang.OutOfMemoryError:Unable to create new native thread
就意味着Java应用程序已达到其可以启动线程数量的极限了。
原因分析
当JVM向OS请求创建一个新线程时,而OS却无法创建新的native线程时就会抛出Unable to create new native thread
错误。一台服务器可以创建的线程数依赖于物理配置和平台,建议运行下文中的示例代码来测试找出这些限制。总体上来说,抛出此错误会经过以下几个阶段:
- 运行在JVM内的应用程序请求创建一个新的线程
- JVM向OS请求创建一个新的native线程
- OS尝试创建一个新的native线程,这时需要分配内存给新的线程
- OS拒绝分配内存给线程,因为32位Java进程已经耗尽内存地址空间(2-4GB内存地址已被命中)或者OS的虚拟内存已经完全耗尽
Unable to create new native thread
错误将被抛出
解决方案
有时,你可以通过在OS级别增加线程数限制来绕过这个错误。如果你限制了JVM可在用户空间创建的线程数,那么你可以检查并增加这个限制:
// macOS 10.12上执行
$ ulimit -u
709
当你的应用程序产生成千上万的线程,并抛出此异常,表示你的程序已经出现了很严重的编程错误,我不觉得应该通过修改参数来解决这个问题,不管是OS级别的参数还是JVM启动参数。更可取的办法是分析你的应用是否真的需要创建如此多的线程来完成任务?是否可以使用线程池或者说线程池的数量是否合适?是否可以更合理的拆分业务来实现.....
4.6 超出交换空间
java.lang.OutOfMemoryError:Out of swap space?
Java应用程序在启动时会指定所需要的内存大小,可以通过-Xmx
和其他类似的启动参数来指定。在JVM请求的总内存大于可用物理内存的情况下,操作系统会将内存中的数据交换到磁盘上去,你可以将它理解成虚拟内存。
Out of swap space?
表示交换空间也将耗尽,并且由于缺少物理内存和交换空间,再次尝试分配内存也将失败。
原因分析
当应用程序向JVM native heap请求分配内存失败并且native heap也即将耗尽时,JVM会抛出Out of swap space
错误。该错误消息中包含分配失败的大小(以字节为单位)和请求失败的原因。
Native Heap Memory是JVM内部使用的Memory,这部分的Memory可以通过JDK提供的JNI的方式去访问,这部分Memory效率很高,但是管理需要自己去做,如果没有把握最好不要使用,以防出现内存泄露问题。JVM 使用Native Heap Memory用来优化代码载入(JTI代码生成),临时对象空间申请,以及JVM内部的一些操作。
这个问题往往发生在Java进程已经开始交换的情况下,现代的GC算法已经做得足够好了,当时当面临由于交换引起的延迟问题时,GC暂停的时间往往会让大多数应用程序不能容忍。
java.lang.OutOfMemoryError:Out of swap space?
往往是由操作系统级别的问题引起的,例如:
- 操作系统配置的交换空间不足。
- 系统上的另一个进程消耗所有内存资源。
还有可能是本地内存泄漏导致应用程序失败,比如:应用程序调用了native code连续分配内存,但却没有被释放。
解决方案
解决这个问题有几个办法,通常最简单的方法就是增加交换空间,不同平台实现的方式会有所不同,比如在Linux下可以通过如下命令实现:
# 原作者使用,由于我手里并没有Linux环境,所以并未测试
# 创建并附加一个大小为640MB的新交换文件
swapoff -a
dd if=/dev/zero of=swapfile bs=1024 count=655360
mkswap swapfile
swapon swapfile
Java GC会扫描内存中的数据,如果是对交换空间运行垃圾回收算法会使GC暂停的时间增加几个数量级,因此你应该慎重考虑使用上文增加交换空间的方法。
如果你的应用程序部署在JVM需要同其他进程激烈竞争获取资源的物理机上,建议将服务隔离到单独的虚拟机中
但在许多情况下,您唯一真正可行的替代方案是:
- 升级机器以包含更多内存
- 优化应用程序以减少其内存占用
当您转向优化路径时,使用内存转储分析程序来检测内存中的大分配是一个好的开始。
4.7 请求数组大小超出VM限制
java.lang.OutOfMemoryError:Requested array size exceeds VM limit
Java对应用程序可以分配的最大数组大小有限制。不同平台限制有所不同,但通常在1到21亿个元素之间。
当你遇到Requested array size exceeds VM limit
错误时,意味着你的应用程序试图分配大于Java虚拟机可以支持的数组。
原因分析
该错误由JVM中的native code
抛出。 JVM在为数组分配内存之前,会执行特定于平台的检查:分配的数据结构是否在此平台中是可寻址的。
你很少见到这个错误是因为Java数组的索引是int类型。 Java中的最大正整数为2 ^ 31 - 1 = 2,147,483,647。 并且平台特定的限制可以非常接近这个数字,例如:我的环境上(64位macOS,运行Jdk1.8)可以初始化数组的长度高达2,147,483,645(Integer.MAX_VALUE-2)。如果再将数组的长度增加1到Integer.MAX_VALUE-1会导致熟悉的OutOfMemoryError:
Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit
但是,在使用OpenJDK 6的32位Linux上,在分配具有大约11亿个元素的数组时,您将遇到Requested array size exceeded VM limit
的错误。
解决方案
java.lang.OutOfMemoryError:Requested array size exceeds VM limit
可能会在以下任一情况下出现:
- 数组增长太大,最终大小在平台限制和
Integer.MAX_INT
之间 - 你有意分配大于
2 ^ 31-1
个元素的数组
在第一种情况下,检查你的代码库,看看你是否真的需要这么大的数组。也许你可以减少数组的大小,或者将数组分成更小的数据块,然后分批处理数据。
在第二种情况下,记住Java数组是由int索引的。因此,当在平台中使用标准数据结构时,数组不能超过2 ^ 31-1个元素。事实上,在编译时就会出错:error:integer number too large
。
4.8 内存杀手
Out of memory:Kill process or sacrifice child
为了理解这个错误,我们需要补充一点操作系统的基础知识。操作系统是建立在进程的概念之上,这些进程在内核中作业,其中有一个非常特殊的进程,名叫“内存杀手(Out of memory killer)”。当内核检测到系统内存不足时,OOM killer被激活,然后选择一个进程杀掉。哪一个进程这么倒霉呢?选择的算法和想法都很朴实:谁占用内存最多,谁就被干掉。
当可用虚拟虚拟内存(包括交换空间)消耗到让整个操作系统面临风险时,就会产生Out of memory:Kill process or sacrifice child
错误。在这种情况下,OOM Killer会选择“流氓进程”并杀死它。
原因分析
默认情况下,Linux内核允许进程请求比系统中可用内存更多的内存,但大多数进程实际上并没有使用完他们所分配的内存。这就跟现实生活中的宽带运营商类似,他们向所有消费者出售一个100M的带宽,远远超过用户实际使用的带宽,一个10G的链路可以非常轻松的服务100个(10G/100M)用户,但实际上宽带运行商往往会把10G链路用于服务150人或者更多,以便让链路的利用率更高,毕竟空闲在那儿也没什么意义。
Linux内核采用的机制跟宽带运营商差不多,一般情况下都没有问题,但当大多数应用程序都消耗完自己的内存时,麻烦就来了,因为这些应用程序的内存需求加起来超出了物理内存(包括 swap)的容量,内核(OOM killer)必须杀掉一些进程才能腾出空间保障系统正常运行。就如同上面的例子中,如果150人都占用100M的带宽,那么总的带宽肯定超过了10G这条链路能承受的范围。此时Linux就会启动OOM Killer选择“流氓进程”并杀死它。
解决方案
解决这个问题最有效也是最直接的方法就是升级内存,其他方法诸如:调整OOM Killer配置、水平扩展应用,将内存的负载分摊到若干小实例上..... 我们不建议的做法是增加交换空间,具体原因已经在前文说过。
PS:博文仅作为个人学习笔记,如有错误欢迎指正,转载请注明出处~
本文的参考文档可能有多
参考文档:
3. 操作系统-程序计数器
4. 线程和虚拟机栈的关系
6. JVM 程序计数器
11. 栈帧、局部变量表、操作数栈
13. plumbr官网的电子书:java.lang.OutOfMemoryError
14. 《冯·诺依曼结构》百度百科