文章目录
内存区域
程序计数器
线程私有 ,可以看作是当前线程所执行的字节码的行号指示器,字节码解释器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
java虚拟机的多线程是通过线程轮流切换、分配处理器时间的方式来实现的,即任何时刻,一个处理器只会执行一条线程,因此,为了线程切换后能恢复到原本的执行位置,每个线程就需要一个独立的程序计数器。
如果执行的是一个java方法,记录的是正在执行的虚拟机字节码指令的地址,如果是一个本地方法,则计数器的值应为空。
程序计数器是唯一一个没有规定任何OutOfMemoryError情况的区域
java虚拟机栈
线程私有,他的生命周期与线程相同。
虚拟机栈描述的是java方法执行的线程内存模型,每个方法被执行的时候,java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息,一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈从入栈到出栈的过程
"栈"一般指的就是虚拟机栈
局部变量表存放了编译器可知的各种Java虚拟机基本数据类型、对象引用类型、returnAddress类型(指向一条字节码指令的地址)这些数据类型在局部变量表中的存储空间以局部变量槽表示,其中64位的long和double会占用两个变量槽,其余数据类型只会占用一个。
局部变量表所需的内存空间在编译期间完成分配,运行期间不会改变局部变量表的“大小”,这里的大小指的是槽的数量。而虚拟机使用多大的内存空间来实现一个变量槽则取决于不同虚拟机的实现。
基本类型 大小 取值范围 装箱基本类型
int 4个字节 -2^31 ~ 2^31-1 Integer
char 2个字节 Character
byte 1个字节 -2^7 ~ 2^7-1 Byte
short 2个字节 -2^15 ~ 2^15-1 Short
long 8个字节 -2^63 ~ 2^63-1 Long
float 4个字节 Float
double 8个字节 Double
boolean 1、4个字节 true ~ false Boolean
当线程请求的栈深度大于虚拟机允许的深度时,抛出StackOverflowError
如果java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存空间时会抛出OutOfMemoryError
本地方法栈
线程私有,与虚拟机栈发挥的作用是相似的,区别只是本地方法栈为所使用到的本地方法服务
本地方法栈使用的语言、方式、数据结构取决于不同的虚拟机,甚至有的虚拟机(例如HotSpot)直接将本地方法栈与虚拟机栈合二为一。
当线程请求的栈深度大于虚拟机允许的深度时,抛出StackOverflowError
如果java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存空间时会抛出OutOfMemoryError
java堆
线程共享。java Heap是虚拟机所管理的内存中最大的一块,在虚拟机启动时创建,此内存的唯一目的就是存放对象实例。几乎所有的对象实例都在这里分配内存。
java堆是垃圾收集器管理的内存区域。
java 堆可以处于物理上不连续的内存空间中,但是在逻辑上他应该被视为连续的。
java堆可以是固定或者可扩展的,但是当前主流的java虚拟机都是按照可扩展来实现的,通过参数-Xmx和-Xms来设定,当堆没有内存完成实例分配以及无法再扩展时,将抛出OutOfMemoryError
方法区
线程共享,用于存储已被虚拟机加载的类型信息、常量、静态常量、即时编译器编译后的代码缓存等数据,java虚拟机规范把方法区描述为堆的一个逻辑部分,但是他还有一个别名叫做“non heap”,目的就是与java堆区分开来。
方法区与永久代,实际上两者并不等价,因为仅仅是以前的HotSpot虚拟机使用永久代来实现方法区而已,这样的好处是GC能够像管理堆一样管理方法区,但是这种设计会导致了java应用更容易遇到内存溢出的问题。于是在jdk7时,HotSpot把原本放在永久代的字符串常量池、静态变量等移出。jdk8时把在永久代的剩余内容,主要是类型信息全部移到了元空间(Meta
space)中。
java虚拟机规范对方法区的规范是非常宽松的,除了和java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择实现垃圾收集,因为垃圾收集行为在这个区域是比较少出现的,这区域的回收目标主要是针对常量池的回收和对类型的卸载,一般来收这个区域的回收效果并不令人满意,尤其是类型卸载,条件相当苛刻,但是这部分的回收有时又确实是有必要的,因为也可能导致内存泄漏
全局字符串池(string pool也有叫做string literal pool)
由上一条可知,在JDK7.0版本,字符串常量池被移到堆中。
全局字符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool中(记住:string pool中存的是引用值而不是具体的实例对象,具体的实例对象是在堆中开辟的一块空间存放的。)。
在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个哈希表,里面存的是驻留字符串(也就是我们常说的用双引号括起来的)的引用(而不是驻留字符串实例本身),也就是说在堆中的某些字符串实例被这个StringTable引用之后就等同被赋予了”驻留字符串”的身份。这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。
静态常量池,即Class文件中的常量池
我们都知道,class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的字面量(Literal)和符号引用(Symbolic References)。
字面量就是我们所说的常量概念,如文本字符串、被声明为final的常量值等。
符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用区分一下,直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)
常量池(constant_pool)指的是在编译期被确定,并被保存在已编译的.class文件中的一些数据。它包括了关于类、方法、接口等中的常量,也包括字符串常量和符号引用。
常量池主要用于存放两大类常量:字面量(Literal)和符号引用量(SymbolicReferences)
字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等
符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:类和接口的全限定名; 字段名称和描述符; 方法名称和描述符
Java中八种基本类型的包装类的大部分都实现了常量池技术,它们是Byte、Short、Integer、Long、Character、Boolean,另外两种浮点数类型的包装类(Float、Double)则没有实现。另外Byte,Short,Integer,Long,Character这5种整型的包装类也只是在对应值在-128到127时才可使用对象池。
常量池的每一项常量都是一个表,一共有如下表所示的11种各不相同的表结构数据,这每个表开始的第一位都是一个字节的标志位(取值1-12),代表当前这个常量属于哪种常量类型。
运行时常量池
是方法区的一部分。jdk8后以元空间为实现
jvm虚拟机在完成类加载后,将class文件中的常量池存放到方法区的运行时常量池中,我们常说的常量池,就是指方法区中的运行时常量池。
jvm在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm就会将class常量池中的内容存放到运行时常量池中。class常量池中存的是字面量和符号引用,也就是说他们存的并不是对象的实例,而是对象的符号引用值。而经过解析(resolve)之后,也就是把符号引用替换为直接引用,解析的过程会去查询全局字符串池,也就是我们上面所说的StringTable,以保证运行时常量池所引用的字符串与全局字符串池中所引用的是一致的。
运行时常量池相对于CLass文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入CLass文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是String类的intern()方法。
运行时常量池是方法区的一部分,自然也会受到方法区内存的限制,当常量池无法再申请到内存时抛出OutOfMemoryError
常量池总结
1.全局常量池在每个VM中只有一份,存放的是字符串常量的引用值。
2.class常量池是在编译的时候每个class都有的,在编译阶段,存放的是常量的符号引用。
3.运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中,也就是说,每个class都有一个运行时常量池,类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。
举例
public class HelloWorld {
public static void main(String []args) {
String str1 = “abc”;
String str2 = new String(“def”);
String str3 = “abc”;
String str4 = str2.intern();
String str5 = “def”;
System.out.println(str1 == str3);//true
System.out.println(str2 == str4);//false
System.out.println(str4 == str5);//true
}
}
回到上面的那个程序,现在就很容易解释整个程序的内存分配过程了,首先,在堆中会有一个”abc”实例,全局StringTable中存放着”abc”的一个引用值,然后在运行第二句的时候会生成两个实例,一个是”def”的实例对象,并且StringTable中存储一个”def”的引用值,还有一个是new出来的一个”def”的实例对象,与上面那个是不同的实例,当在解析str3的时候查找StringTable,里面有”abc”的全局驻留字符串引用,所以str3的引用地址与之前的那个已存在的相同,str4是在运行的时候调用intern()函数,返回StringTable中”def”的引用值,如果没有就将str2的引用值添加进去,在这里,StringTable中已经有了”def”的引用值了,所以返回上面在new str2的时候添加到StringTable中的 “def”引用值,最后str5在解析的时候就也是指向存在于StringTable中的”def”的引用值,那么这样一分析之后,下面三个打印的值就容易理解了。上面程序的首先经过编译之后,在该类的class常量池中存放一些符号引用,然后类加载之后,将class常量池中存放的符号引用转存到运行时常量池中,然后经过验证,准备阶段之后,在堆中生成驻留字符串的实例对象(也就是上例中str1所指向的”abc”实例对象),然后将这个对象的引用存到全局String Pool中,也就是StringTable中,最后在解析阶段,要把运行时常量池中的符号引用替换成直接引用,那么就直接查询StringTable,保证StringTable里的引用值与运行时常量池中的引用值一致,大概整个过程就是这样了。
直接内存
直接内存不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域。
jdk1.4后引入的NIO,引入了一种基于通道与缓冲区的IO方式,它可以使用Native函数库直接分配堆外内存,然后通过存储在堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样能够在一些场景下提高性能,因为避免了在java堆和Native堆中来回复制数据。
直接内存不会受到堆大小的限制,但会受到本机总内存的限制。
对象的创建
-
当虚拟机遇到一条字节码new指令时,将去检查这个指令的参数能否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析、和初始化过,如果没有,那必须先执行相应的类加载过程。
-
检查完成后,将为新生对象分配内存,对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上等同于把一块确定大小的内存块从堆中划分出来。
-
假设java堆是绝对规整的,使用过的内存在一边,没有使用的在另一边,中间放着一个指针作为分界点的指示器,那分配内存就仅仅是把那个指针向空间空间挪动一段对象大小等同的距离,这种分配方式称为“指针碰撞”。
-
如果堆不是绝对规整的,就无法进行指针碰撞了。虚拟机就必须维护一个列表,记录哪些内存是可用的,在分配的时候从列表中找到一块足够的空间划分给对象实例,并更新列表,这种方式称为空闲列表。
-
选择哪种分配方式取决于java堆是否规整,而是否规整又由所采用的垃圾收集器是否带有空间压缩整理的能力决定。因此,当使用的是Serial、ParNew等带有压缩整理过程的收集器时,采用的分配算法是指针碰撞,简单且高效。而当使用CMS这种基于清除算法的收集器时,理论上只能采用空间列表来分配内存。
-
对象创建在虚拟机是非常频繁的行为,需要保证并发的安全性,即可能出现正在给对象A分配内存时,指针没来得及修改,对象B又同时使用了原来的指针进行分配内存的情况。一种解决方案是进行同步处理,实际上虚拟机采用CAS加失败重试的方式保证更新操作的原子性。另一种方式是把内存分配的动作按照线程划分在不同的空间中进行,即每个线程预先分配一小块内存,这种方式称为“本地线程分配缓冲”(TLAB),只有当缓冲区用完了,分配新的缓冲区时才需要同步锁定。
-
内存分配完成后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零,如果使用了TLAB,这项工作也可以提前至TLAB分配时进行。初始化为零保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,是程序能够访问到这些字段的数据类型对应的零值
-
接下来虚拟机还会对对象进行必要的设置,例如这个对象是哪个类的实例,如何找到类的元数据信息,对象的哈希码(实际上哈希码会延后到真正调用hashCode方法时才计算)、对象的GC年龄,这些信息存放在对象的对象头之中。
-
到这一步,从虚拟机的视角来看,对象的初始化已经完成、但是从java程序的视角看,才刚刚开始,构造函数,即Class的<init>方法还没有执行,所有字段还为零值,一般来说,new指令后面会接着执行init方法,这样一个对象才算完全被构造出来。
对象的内存布局
HotSpot中,对象的内存布局可以划分为三个部分:对象头、实例数据以及对齐填充
对象头
对象头包含两类信息,Mark word和Klass *
Mark word
Mark word
用于存储对象自身的运行时数据,例如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程id,偏向时间戳等。在32位和64位的虚拟机中,分别为32个比特和64个比特(未开启压缩指针)。Mark
word时一个有着动态定义的数据结构,即根据对象的状态复用自己的存储空间。
存储内容 | 标志位 | 状态 |
---|---|---|
对象哈希码、分代年龄 | 01 | 未锁定 |
指向锁记录的指针 | 00 | 轻量级锁定 |
指向重量级锁的指针 | 10 | 重量级锁定、膨胀 |
空 不需要记录信息 | 11 | GC标记 |
偏向线程id,偏向时间戳,对象分代年龄 | 01 | 可偏向 |
由上表可知,例如在32位的vm中,在对象未被同步锁锁定的状态下,Mark
word32个比特中的2个存储锁标志位、25个存储哈希码,4个存储分代年龄、1个比特固定为0.
Klass
对象头的另一部分是类型指针 Klass*,即对象指向它的类型元数据的指针。然而并不是所有的虚拟机都必须在对象数据上保留类型执政,即查找对象的元数据信息并不一定要经过对象本身,此外如果对象是一个数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通对象的元数据信息确定java对象的大小,但是如果数据的长度不确定,将无法通过元数据的信息推断出数组的大小。
实例数据
接下来存储的是实例数据,HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops
从以上的分配顺序可以看出,相同宽度的字段总是被放在一起,在满足这个的前提下,父类中定义的变量会出现在子类的前面,同时若+XX:CompactFieldswei为true(默认就为true),那子类中较窄的变量也允许插入父类变量的空隙之中,以节省空间。
对齐填充
第三部分是对齐填充,HotSpot虚拟机要求对象起始地址必须是8字节的倍数,对象头已经被设计成8的倍数(1倍或者2倍),如果对象实例数据部分没有对其的化,就需要通过对其填充来补全。
对象的访问定位
主流的访问方式主要有句柄和直接指针两种:
句柄访问:java堆中可能会划分一块内存作为句柄池,对象引用存储的就是对象的句柄地址,而句柄中包含了对象的实例数据与类型数据各自具体的地址信息
直接指针:通过这种方式进行对象的访问的话,就必须考虑如何放置访问类型数据的相关信息(Klass),此时对象引用存储的直接就是对象地址,此时访问对象本身的话,少了一次访问的开销
两种方式各有优势,
句柄的优势是对象引用存储的是句柄池的地址,对象被移动时(垃圾收集时移动对象十分普遍)时只会改变句柄中的实例数据指针,对象引用本身不需要被修改
直接指针的好处就是速度更快,节省了一次指针定位的时间开销。 HotSpot主要使用第二种进行对象访问
类的加载过程
JVM把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程称为虚拟机的类加载机制。
与其他在编译时进行连接的语言不同,java语言里面,类型的加载、连接、初始化都是在程序运行期间完成的。
加载时机
一个类型从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期包括加载、验证、准备、解析、初始化、使用、卸载七个阶段。其中验证、准备、解析三个部分统称为连接。
而加载、验证、准备、初始化和卸载这五个阶段的开始顺序是确定的,但解析阶段则不一定,在某些情况下它可以在初始化阶段之后再开始。(“开始顺序”是因为它们通常都是互相交叉混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段)
什么时候进行加载,并未有强制的约束,但是java虚拟机规范规定了有且仅有六种情况必须立即对类进行 “初始化”(而加载、验证、准备阶段则必须在此之前开始)
-
使用new关键字实例化对象时
-
读取或者设置一个类的静态字段时(被final修饰,已在编译器把结果放入常量池的字段除外)
-
调用一个类的静态方法时
-
使用java.lang.reflect包的方法对类型进行反射调用时
-
当初始化类时,如果发现其父类还没有进行初始化,则需要先触发父类的初始化
-
虚拟机启动时,会初始化含有Main方法的类
-
当一个接口定义了一个默认方法。如果有接口的实现发生了初始化,则接口要在其之前进行初始化
-
如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄的对应的类没有进行过初始化,则触发其初始化
-
接口也有初始化过程,不同的是接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用父接口的时候(例如引用接口中定义的常量)才会初始化
加载
在加载阶段,java虚拟机需要完成以下三件事情
-
通过一个类的全限定名获取定义此类的二进制字节流
-
把这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
-
在堆内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
相对于类加载的其他阶段,“加载”阶段是可控性最强的阶段,因为非数组类型的"加载"阶段可以使用jvm内置的引导类加载器完成,也可以使用用户自定义的类加载器去完成
加载阶段与连接阶段的部分动作(例如部分验证动作)是交叉进行的,但是这两个阶段的开始时间仍然保持着固定的先后顺序
验证
验证是连接阶段的第一步,大致上会完成下面四个阶段的校验动作
准备
准备阶段是正式为类中定义的静态变量分配内存并设置初始值的过程。
例如
public static int value = 123;
此变量在准备阶段过后的初始值是0而不是123,把value赋值为123的动作要到类的初始化阶段才会被执行。
但如果是常量,例如
public static final value = 123;
则在准备阶段此变量的值就为123
解析
解析阶段是jvm把常量池内的符号引用替换为直接引用的过程。
- 符号引用:以一组符号来描述所引用的目标,符号可能是任何形式的字面量,引用的目标不一定是已经加载到虚拟机内存当中的内容
- 直接引用:可以直接指向目标的指针、相对偏移量、或者是一个能间接定位到目标的句柄,引用的目标必定在虚拟机的内存中存在
虚拟机可以根据需要自行判断,是类在被加载器加载时就对符号引用进行解析,或者在等到将要被使用的时候才进行解析
解析阶段在某些情况下它可以在初始化阶段之后再开始。
初始化
初始化阶段就是执行类构造器<cliinit>的过程,<cliinit>是javac编译器自动生成的产物。
- <cliinit>是由编译器自动收集类中的所有静态变量的赋值动作和静态语句块中的语句合并产生的,收集的顺序由语句的顺序决定。(静态语句块对定义在语句块后的变量,只能进行赋值,不能访问)
- <cliinit>不需要显式的调用父类构造器,因为虚拟机会保证在子类的<cliinit>方法执行之前,父类的<cliinit>已经执行完毕。因此jvm中第一个执行完毕<cliinit>的类必然是Object
- <cliinit>对于类或接口来说不是必须的,如果没有静态语句块或者赋值操作,那么可以不为这个类生成<cliinit>方法
- 执行接口的<cliinit>时,父接口的<cliinit>不一定已经执行,因为只有当父接口定义的变量被使用时,才会初始化父接口,此外,接口的实现类在初始化时也一样不会执行接口的<cliinit>方法
如果多个线程同时去初始化一个类,那么只有一个线程去执行<cliinit>方法,其他线程阻塞等待,当执行完毕,其他线程被唤醒时,则不会再次进入<cliinit>方法,因为在同一个类加载器下,一个类型只会被初始化一次
Java内存模型
java虚拟机规范定义了一种 java内存模型(java memory model,JMM)用来屏蔽各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台上都能达到一致的内存访问效果
主内存和工作内存
java内存模型的主要目的是定义程序中各种变量的访问规则,即把变量值存储到内存和从内存中取出这样的底层细节。此处的变量指的是实例字段、静态字段和数组对象的元素,但是不包含局部变量和方法参数,因为后者是线程私有的,并不存在线程共享的问题。
java内存模型规定了所有的变量都存储在主内存中,但是每条线程都有自己的工作内存,线程中的工作内存保存了被该线程使用的变量的主内存副本。线程对变量的所有操作(读写)都必须在工作内存中进行,而不能直接读写主内存的数据。不同线程之间也无法访问彼此的工作内存的变量,即线程之间变量值的传递需要通过主内存来完成