1. JVM内存模型
Java 虚拟机在执⾏ Java 程序的过程中会把它管理的内存划分成若⼲个不同的数据区域。JDK1.8 和之前的版本略有不同,下⾯会介绍到。
首先对于一个进程来说,它包含多个线程,每个线程都有其独立的内存区域,包括:虚拟机栈,本地方法栈和程序计数器。
程序计数器:线程私有
记录当前线程所执行到的字节码的行号。
每个线程都有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存区域。
它是唯一没有OutOfMemoryError情况的内存区域。
它的⽣命周期随着线程的创建⽽创建,随着线程的结束⽽死亡。
程序计数器在哪些地方用到了?
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复登基础功能都需要依赖这个计数器来完成。
Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。
Java虚拟机栈:线程私有
描述的是 Java ⽅法执⾏的内存模型,每个Java方法在执行的同时都会创建一个栈帧,用于存储 局部变量表 、操作数栈、动态链接、⽅法出⼝等信息。
每个Java方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
Java 虚拟机栈也是线程私有的,每个线程都有各⾃的Java虚拟机栈,⽽且随着线程的创建⽽创建,随着线程的死亡⽽死亡。
局部变量表
局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、short、int、float、long、double)、对象引⽤(reference类型,它不同于对象本身,可能是⼀个指向对象起始地址的引⽤指针,也可能是指向⼀个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
这些数据类型在局部变量表中的存储空间是以局部变量槽来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个。
局部变量表所需的内存空间在编译期间完成分配。当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。
StackOverFlowError:若Java虚拟机栈的内存⼤⼩不允许动态扩展,那么 当线程请求栈的深度超过当前Java虚拟机栈的最⼤深度的时候 ,就抛出StackOverFlowError异常。(典型的场景有:递归调用和死循环)
OutOfMemoryError:若 Java 虚拟机栈的内存⼤⼩允许动态扩展,且 当线程请求栈时内存⽤完了 ,⽆法再动态扩展了,此时抛出OutOfMemoryError异常。
在栈上分配对象
大多数对象都在堆上分配内存空间,但是由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配,标量替换优化手段已经导致对象在堆上分配不是那么绝对了。
本地方法栈:线程私有
本地方法栈和虚拟机栈所发挥的作⽤⾮常相似,区别是: 虚拟机栈为虚拟机执⾏ Java ⽅法 (也就是字节码)服务,⽽本地⽅法栈则为虚拟机使⽤到的 Native ⽅法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合⼆为⼀。本地⽅法被执⾏的时候,在本地⽅法栈也会创建⼀个栈帧,⽤于存放该本地⽅法的局部变量表、操作数栈、动态链接、出⼝信息。
⽅法执⾏完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和OutOfMemoryError 两种异常。
看完了线程私有的内存区域,在来看看线程共享的内存区域:
堆:线程共享
Java 虚拟机所管理的内存中最⼤的⼀块,Java 堆是所有线程共享的⼀块内存区域,在虚拟机启动时创建。
此内存区域的唯⼀⽬的就是存放对象实例,⼏乎所有的对象实例以及数组都在这⾥分配内存。
堆分区
Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。
从垃圾回收的⻆度,由于现在收集器基本都采⽤分代垃圾收集算法,所以Java堆还可以细分为:新⽣代和⽼年代。
新生代再细致⼀点有:Eden空间、From Survivor、To Survivor空间等。
进⼀步划分的⽬的是更好地回收内存,或者更快地分配内存。
上图所示的 eden区、s0区、s1区都属于新⽣代,tentired 区属于⽼年代。⼤部分情况,对象都会⾸先在 Eden 区域分配,在⼀次新⽣代垃圾回收后,如果对象还存活,则会进⼊ s0 或者 s1,并且对象的年龄还会加 1(Eden区i>Survivor 区后对象的初始年龄变为1),当它的年龄增加到⼀定程度(默认为15岁),就会被晋升到⽼年代中。对象晋升到⽼年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
方法区:线程共享
它⽤于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
⽅法区也被称为永久代。很多⼈都会分不清⽅法区和永久代的关系,为此我也查阅了⽂献。永久代是对方法区的一种实现方式。方法区是一种定义,永久代是一种实现。
运⾏时常量池
运⾏时常量池是⽅法区的⼀部分。Class ⽂件中除了有类的版本、字段、⽅法、接⼝等描述信息外,还有常量池信息(⽤于存放编译期⽣成的各种字⾯量和符号引⽤)
既然运⾏时常量池时⽅法区的⼀部分,⾃然受到⽅法区内存的限制,当常量池⽆法再申请到内存时会抛出 OutOfMemoryError 异常。
JDK1.7及之后版本的 JVM 已经将运⾏时常量池从⽅法区中移了出来,在 Java 堆(Heap)中开辟了⼀块区域存放运⾏时常量池 。字符串常量池1.7以后也是放在堆中
JDK1.7和1.8 内存模型的区别
JDK1.7JVM内存模型:
线程私有:Java虚拟机栈、本地方法栈、程序计数器
线程共享:方法区、堆
JDK1.8JVM内存模型:
JDK1.8与1.7最大的区别是1.8将永久代(方法区)取消,取而代之的是元空间。
JDK1.7方法区是由永久代实现的,JDK1.8方法区是由元空间实现的,元空间属于本地内存,所以元空间的大小受本地内存的限制。
直接内存
直接内存并不是虚拟机运⾏时数据区的⼀部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使⽤。⽽且也可能导致 OutOfMemoryError 异常出现。
2. Java内存模型(JMM)
Java内存模型规定所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本的拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
主内存:主要对应Java堆中的对象实例数据部分。(寄存器,高速缓存)
工作内存:对应于虚拟机栈中的部分区域。(硬件的内存)
3 . Java对象的创建过程
① 类加载检查 : 虚拟机遇到⼀条 new 指令时,⾸先将去检查这个指令的类参数是否能在常量池中定位到这个类的符号引⽤,并且检查这个符号引⽤代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执⾏相应的类加载过程。(详细的类加载机制会新出一篇博客)
② 分配内存 : 在类加载检查通过后,接下来虚拟机将 为新⽣对象分配内存 。对象所需的内存⼤⼩在类加载完成后便可确定,为对象分配空间的任务等同于把⼀块确定⼤⼩的内存从 Java 堆中划分出来。分配⽅式有 “ 指针碰撞 ” 和 “ 空闲列表 ” 两种, 选择那种分配⽅式由 Java 堆是否规整决定,⽽Java堆是否规整⼜由所采⽤的垃圾收集器是否带有压缩整理功能决定。
③ 初始化零值 :内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这⼀步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使⽤,程序能访问到这些字段的数据类型所对应的零值。
④ 设置对象头 : 初始化零值完成之后,虚拟机要对对象进⾏必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运⾏状态的不同,如是否启⽤偏向锁等,对象头会有不同的设置⽅式。
⑤ 执⾏ init ⽅法 : 在上⾯⼯作都完成之后,从虚拟机的视⻆来看,⼀个新的对象已经产⽣了,但从Java 程序的视⻆来看,对象创建才刚开始, ⽅法还没有执⾏,所有的字段都还为零。所以⼀般来说,执⾏ new 指令之后会接着执⾏ ⽅法,把对象按照程序员的意愿进⾏初始化,这样⼀个真正可⽤的对象才算完全产⽣出来。
[注意]:执行方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。