第二章:Java内存区域和内存溢出异常
1. 内存区域
程序计数器:线程私有。指示当前线程执行的字节码的行号。是jvm中唯一不会发生oom的区域。
栈:线程私有(每个线程有一个栈)。对于每个方法会创建一个栈帧,栈帧是虚拟机进行方法调用和方法执行的数据结构,栈帧包括局部变量表、操作数栈等。可能会有超出栈深度的异常和oom异常。
堆:线程共享。存放实例变量,分为新生代和老年代,可以是内存中不连续的空间。当分配空间不足出现oom异常。
方法区:线程共享。存放类信息、常量、静态变量等,还包括一个运行时常量池,当空间不足时出现oom异常。
运行时常量池:是方法区的一部分,class文件的常量池部分在类加载进入方法区后存放的地方,运行时常量池还具备动态性,在运行期间也可能将新的常量放入池中。
直接内存:堆外内存,directbuffer,当主机内存不够时oom。
2. 对象的创建
1. 一般是从new指令开始的,虚拟机在遇到new指令后,先去方法区的运行时常量池看有没有这个类的符号引用,并检查这个符合引用代表的类是否已经经过加载、解析和初始化,如果没有就需要进行相应的类加载过程。
2. 完成了这个符号引用的类的类加载之后,虚拟机就会进行对象的内存分配,对象内存分配需要注意两点。一、对象所需的内存大小在类加载完成之后就可以确定,而对象内存分配的方式主要有两种:一般新生代(没有内存碎片,采用复制算法)的采用指针碰撞,指针碰撞是指内存空间分为已使用的和空闲的两部分,内存分配只需要移动指针;老年代采用空闲列表,空闲列表是指虚拟机维护一个列表,标记了哪些内存块可用,哪些不可用。二、考虑线程安全的问题,给两个对象分配内存,如果指针还没来得及修改,另一个对象又使用原来的指针那就会有问题,解决方法有两种,一种是虚拟机采用cas加上失败重试保证同步;另一种是每个线程在堆种预先分配一小块内存(本地线程分配缓冲),每次对象分配都在这个内存上,用完了再重新申请。
3. 内存空间分配完成之后,虚拟机会对分配的内存空间赋零值,这保证了对象的实例字段在代码中不赋值就能直接使用。
4. 接下来,虚拟机会对对象头进行赋值,包括gc年龄、对象的哈希码等。
5. 完成了上述步骤,虚拟机正式完成了对象的创建,但是对于开发者才只是new了一个对象,接下来会执行对象的构造方法进行需要的初始化。至此对象创建正式完成。
3. 对象内存分配
上文说的是一个对象的创建过程,这里阐述对象在堆上分配的几点原则。如果启动了本地线程分配缓冲,那么对象会分配在该线程的缓冲内存区域。下面阐述几点原则:
- 对象优先分配在Eden区。并且当Eden区空间不足时,会触发新生代GC Minor GC。
- 大对象直接进入老年代。可以设置虚拟机参数指定多大的对象是大对象。
- 长期存活的对象进入老年代。对象的GC年龄大于一定值时进入老年代,默认是15。
- 空间分配担保。发生Minor GC之前,虚拟机会检查老年代的连续空间够不够,因为复制算法会讲存活的对象复制到survivor区,如果survivor区空间不够,那就就是用空间分配担保存到老年代的。
4. 对象的内存布局(对象里边是啥)
对象内存布局包括三部分:对象头;实例数据;对齐填充。
对象头:是对象的一些信息,包括gc年龄;哈希码;锁标志;类型指针(指向类元数据,通过这个指针确定这个对象是哪个类的实例)。
实例数据:是对象真正的存储数据信息,包括从父类继承的,或子类定义的各种数据内容。
对齐填充:占位符。
5. 对象的访问定位
在程序中使用对象,需要栈帧中的reference引用数据来操作堆中的具体对象,而如何操作,就是对象访问定位的问题,有两种方式:句柄访问和直接指针访问。
句柄访问:是在堆中划分一小块内存存储对象实例数据堆中的指针和对象类型数据方法区中的指针。
直接访问:reference存储的是堆中实例对象的指针,堆中对象中的对象头保存了到方法区对象类型数据的指针。
第三章:垃圾收集器和内存分配策略
1. 对象的回收
第一个问题,判断对象是否需要回收,一般有两种判断方式,引用计数和可达性分析。可达性分析是指从一系列的GC Root对象开始,如果一个对象不能通过引用链关联上,那么就会被标记为可回收对象。其中,GC Root的对象包括:栈帧中局部变量表中引用的对象、方法区中静态变量引用的对象;方法区中常量引用的对象。补充一点,引用分为强引用、软引用、弱引用和虚引用。
第二个问题,对象回收的步骤。第一个问题阐述了虚拟机如何判断对象是否要回收,在判断完之后其实还未开始对象回收,只是对这个对象进行了一次标记,标记之后会进行一次筛选,如果这个对象有覆盖finalize()方法,并且还未执行过,那么会执行finalize()方法,若在执行这个方法的时候该对象又和引用链的对象挂钩了,那么此对象就可以不被回收;否则就会回收。补充一点,方法区也会有垃圾回收,回收的是废弃常量和无用的类。
2. 垃圾收集算法
(1). 标记-清除算法。先标记出需要回收的对象,然后标记之后统一回收。效率不高、产生内存碎片。用在老年代。
(2). 复制算法。虚拟机中新生代基本都采用这个算法。默认将新生代分为eden和两块survivor(8:1:1),每次使用eden和一块survivor,当空间不足时进行垃圾回收,标记之后将存活的对象复制到另一块survivor,原来的内存都清除。实现简单,效率高,但浪费了空间。补充一点,复制对象时若survivor空间不足,采用分配担保策略,放在老年代。
(3). 标记-整理算法。标记出需要回收的对象之后,让存活的对象移到一边,清除另一边的对象。没有内存碎片,一般用在老年代。
3. 垃圾收集器
首先,GC进行时必须停顿,stop-the-world,因为在进行可达性分析的时候必须保证一致性。其次,GC进行的时间点一般是在安全点或安全区域(例如方法调用处、循环跳转处)。
新生代垃圾收集器:
(1). Serial。单线程收集器。
(2). ParNew。多线程收集器。
(3). Parallel Scavenge。吞吐量优先。吞吐量:运行用户代码时间/(运行用户代码时间+垃圾收集时间)。
老年代垃圾收集器:
(1). CMS。是一种以获取最短回收停顿时间为目标的垃圾收集器,采用标记-清除算法。其垃圾回收分为四步:
初始标记。标记GC Root能直接关联的对象,速度很快,需要停顿。
并发标记。对GC Root的引用链进行跟踪,耗时长,但与用户线程并发执行。
重新标记。修正并发标记期间用户线程继续运行导致的标记变化的部分对象,耗时较短,需要停顿。
并发清除。与用户线程并发执行,对标记的对象进行清除。(也可能是因为CMS注重减小停顿时间,并发标记,清除的原因,所以采用的是标记-清除,而非标记-整理,因为标记-整理需要挪动对象位置)。
特点:这种四步的垃圾收集方式,使得耗时长的并发标记和并发清除能和用户线程并发执行,是CMS的独到之处,低停顿的关键所在。但存在下面三个缺点:
a. 对CPU资源敏感。因为是并发执行的,会跟用户线程抢占CPU资源,可能会带来用户线程性能下降的问题。为此,虚拟机提出了一个增量式并发收集器,延长了GC时间,但减少了对用户线程的影响。
b. 浮动垃圾无法处理。在并发清理的时候,用户线程也可能在产生垃圾,CMS无法处理这种垃圾,只能留给下次GC处理。
c. 内存碎片。标记-清除算法带来的问题。
(2). Serial Old。Serial的老年代版本,采用标记-整理算法。
(3). Parallel Old。Parallel Scavenge的老年代版本,吞吐量优先,采用标记-整理算法。
特殊的G1垃圾收集器。
首先,采用G1垃圾收集器是对原有堆布局的打破,虽然也有新生代,老年代之分,但不再是物理上隔离的区域,而是一块块内存的集合。
第六章:类文件结构
1. Class文件的结构
整个class文件是以无符号数和表为数据类型。
魔数:cafebabe。
主/次版本号:jdk的版本。
常量池:存放两大类的常量:字面量(文本字符abc、final修饰的常量)和符号引用(类和接口的全限定名、字段或方法的名称和描述符),包括类的全限定名、类成员变量名、方法或字段的描述符、变量值等都在这里有。
访问标志:标示类的访问信息(public、abstract等)。
类、父类、接口索引:该类和父类、接口的全限定名。
字段表:类变量(static)和实例变量(非static),不包括局部变量(方法中的变量)。补充一点,内部类可以访问外部类的成员变量、外部类不能直接访问内部类的成员变量,而内部类为了保持对外部类的访问,会自动添加只想外部类实例的字段。
方法表:对类中方法的描述。补充一点,编译器会自动添加类构造器<clinit>和实例构造器<init>的方法。
属性表:属性表不同于前面的这么顺序下来,而是可以在字段表、方法表中携带自己的属性表,属性表有很多属性,code、execption等等,最重要的是code属性。
Code属性:java的程序方法体中的代码在经过javac编译之后存储在class文件的方法表的code属性中。Code属性重要的包括一下两个:
- max_stack。最大栈深度。
- max_locals。局部变量表所需的空间大小。这里用slot表示,对于处理double、long这两个64位的数据类型用2slot外,其余的都用1slot。补充一点,对于实例方法max_locals至少为1,因为有this变量,如果是static方法可以为0。
- code和code_length。存储字节码指令。
2. 字节码指令
Java虚拟机采用面向操作数栈而不是寄存器的架构,所以大部分指令都不包含操作数(指令参数),而只有一个操作码,一共有256个指令,主要包括9类(下面有的没列出):
加载存储指令:load、store。
运算指令:add、sub、mul、div等。
类型转换指令:小范围类型到大范围类型直接转换,否则有i2b、i2c等。
对象创建访问指令:new(创建类实例的指令)、getfield等。
方法调用和返回指令:invokevirtual(调用对象的实例方法)、invokespecial(调用特殊的实例方法,包括实例构造器方法、父类方法、私有方法)、invokestatic(调用类方法,static)。
同步指令:java虚拟机支持方法级和方法内的同步,都是采用管程(minitor),方法级的同步通过方法表的访问标志,方法内的通过minitorenter和minitorexit实现。
第七章:虚拟机的类加载机制
1. 类加载过程
虚拟机将class文件加载到内存,并对数据进行校验、解析和初始化,最终形成可以被虚拟机直接使用的java类型,这就是类加载机制。Class文件从加载进内存到卸载出内存,整个过程包括加载、验证、准备、解析、初始化、使用和卸载,7个步骤,其中验证、准备、解析统称为连接。
类加载的时机:类加载时机就是虚拟机什么时候去加载类(虚拟机规范并未规定什么时候加载类,但严格规定了类初始化的情况)。一般有以下5种情况:
- 遇到new、getstatic、putstatic和invokestatic这四个字节码指令,也就是,使用new关键字实例化对象,读取或设置类的静态字段(static),或调用类的静态方法。
- 反射调用。
- 初始化一个类的时候,如果其父类还未初始化,就会先初始化父类。
- 主类(main方法的类)。
- 动态语言支持。
特别的,有以下几种情况是不会初始化(加载类)的:
- 通过子类引用父类的静态字段,子类不会初始化(静态字段只有直接定义这个字段的类会初始化)。
- 用数组定义来引用类,这个类不会初始化。
- 常量在编译阶段会存入调用类(不是定义常量的类)的常量池中,如果直接调用这个类的常量是不会触发这个类的初始化的。
类加载的过程:
- 加载。三个步骤:通过类的全限定名获取这个类的二进制字节流;将字节流代表的静态存储结构(static)转化为方法区的数据;在方法区中生成一个class对象作为程序访问的入口。
- 验证:虚拟机对class文件的字节流进行安全验证。
- 准备。为类的静态变量(类变量,static)分配内存(在方法区中)并赋零值(只是零值,还未正在赋值,赋值是在初始化阶段的类构造器<clinit>执行的),这里不包括实例变量,实例变量是随着对象实例化在堆中分配内存并赋值的,补充一点,准备阶段对常量(final)会赋自定义的值。
- 解析。解析阶段是将常量池中的符号引用替换为直接引用。也就是class文件中常量池的那些符号引用替换为指针、句柄。
- 初始化。初始化阶段就是类构造器<clinit>的执行过程。对于类构造器,是编译器自动收集静态代码块、static类变量赋值操作创建的方法,按照顺序的,静态代码块只能访问定义在静态代码块之前的静态变量(可以赋值,但不能访问)。补充一点,执行顺序:父类静态代码块—>父类静态变量初始化—>子类静态代码块—>子类静态变量初始化—>父类非静态代码块—>父类成员变量初始化—>字类非静态代码块—>子磊成员变量初始化—>父类构造方法—>子类构造方法。还有静态代码块在类加载的时候执行,静态方法在调用的时候执行。
2. 类加载器
什么是类加载器:通过一个类的全限定名来获取这个类的二进制字节流,实现这个的就是类加载器。对于一个类,需要加载这个类的类加载器和这个类一起才能确定在虚拟机中的唯一性,这句话的意思是要确定这个类是否相等(例如instanceof、equals)除了这个类以外还要保证类加载器也是相同的。
3种系统提供的类加载器:
启动类加载器。加载/bin目录下的类库。
扩展类加载器。加载/bin/ext目录下的类库。
应用程序类加载器。加载用户类路径下(classPath)的类库。
双亲委派模型:以上三种类加载器层次结构从下往上,当一个类加载器收到了类加载请求时,它首先不会自己去加载,而是交给父类去完成,每一层次的类加载器都这样,因此所有的类加载请求都会先传递给顶层的启动类加载器;当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。
线程上下文类加载器:对于像JDBC这种服务提供者接口(SPI),需要加载具体的实现类,这些SPI是由启动类加载器或扩展类加载器加载的,但是具体的实现类是需要由应用程序类加载器加载classPath下的类库,但双亲委派模型上层类加载器对下层类加载器是不可见的,为了解决这个问题,出现了线程上下文类加载器。默认的线程上下文类加载器就是应用程序类加载器。
3. tomcat的类加载机制
绝大多数的java web服务器,都实现了自己定义的类加载器,web服务器需要实现部署在同一服务器上的两个java类库既可以相互隔离也可以相互共享。为了满足这种要求,需要多个classpath路径供用户存放类库。在tomcat目录结构中,有3组目录存放java类库,再加上web应用程序自身的类库,一共4组:
放置在/common目录的类库可以被tomcat和所有web应用程序使用,对应common类加载器。
放置在/server目录的类库可以被tomcat使用,但所有的web应用程序不可用。对应catalina类加载器。
放置在/share目录的类库不可以被tomcat使用,但能被所有的web应用程序共享使用,对应share类加载器。
放置在/webapps/WEB-INF目录的类库不可以被tomcat使用,并且只能被自己的web应用程序使用,不共享,对应webapp类加载器。
Tomcat的类加载机制是典型的双亲委派模型,如下图。
Common类加载器加载的类能被catalina类加载器和share类加载器加载的类看见,但catalina类加载器和shared类加载器之间是相互隔离的,对于部署在同一服务器上的多个web应用程序,可以共享shared类加载器加载多类,但也有各自隔离的webapp类加载器。
第八章:虚拟机字节码执行引擎
1. 方法执行和栈帧
栈帧结构:方法执行完成对应的就是栈帧在栈中的入栈和出栈。因此先对栈帧数据结构进行分析,栈帧结构主要分为4个部分:
局部变量表。存放方法参数和方法内定义的局部变量,class文件中方法表的code属性的max_locals就确定了这个方法所需分配的局部变量表的最大容量(32位以内的1slot,long double 2slot),对于实例方法(非static)其局部变量表的第0slot是this,另外超过局部变量作用域并且有对局部变量表的读写操作时,slot可以复用。补充一点,类变量(包括类静态变量和类成员变量)在类加载的准备阶段都有赋零值的操作,因此没有为类变量赋值也没关系,但是局部变量不可以,局部变量没有赋值不能使用,编译失败。
操作数栈。操作数栈是一个栈结构,在一个方法刚刚开始执行的时候,操作数栈是空的,随着程序的运行会在操作数栈中出栈入栈,而在class文件中方法表的code属性的max_stacks就确定了这个方法的操作数栈的最大深度(32位以内的1slot,long double 2slot)。
动态连接。每个栈帧都有一个指向运行时常量池中该栈帧对应方法的引用,这个就是动态连接。
方法返回地址。
2. 方法调用
方法调用是为了确定被调用方法的版本。Class文件并没有c/cpp编译的连接过程,class文件中存储的只是符号引用,而不是方法在实际运行时的内存入口地址(也就是不是直接引用),需要在类加载的解析阶段将符号引用转化为直接引用,有时是在运行期才能确定直接引用,对于可以在解析阶段确定的这类方法调用叫做解析;需要在运行期才能确定的叫做分派。
解析:编译器可知,运行期不可变。主要包括静态方法、实例构造方法、私有方法和父类方法,这种方法称为非虚方法。对应的指令是invokestatic和invokespecial,特殊的,fianl方法用的指令是invokevirtual,但是也是解析。
分派:分派分为静态分派和动态分派。
静态分派:典型应用-重载。Human man = new Man();这条代码中, 前面的Human称为静态类型,后面的Man称为实际类型,静态分派(或者说重载)是通过静态类型来确定调用的方法版本,而不是实际类型。
动态分派:典型应用-重写。动态分派是根据实际类型来确定调用的方法版本。动态的本质是invokevirtual指令执行的第一步就是确定接收者(方法执行者)的实际类型,会根据实际类型把常量池中的类方法符号引用解析到不同的直接引用上。
宗量:方法的接收者和方法参数一起统称为宗量。静态分派多宗量,动态分派单宗量(方法参数确定的)。
第十章:编译期优化
1. 什么是编译器
Java的编译器分为三种:
前端编译器,javac,将java文件编译成class文件。
后端编译器,JIT,将字节码转变为机器码。
静态提前编译器,AOT,提前将java文件转变成机器码。
需要主要的是javac前端编译器,对运行效率没有任何优化,但提供了很多语法糖等提高编码效率;java对性能的优化集中在后端编译器JIT,这也是为了让不是用javac编译的代码也能享受性能优化。
2. javac编译过程
分为三步:
解析和填充符号表。
插入式注解处理器的注解处理。
分析和字节码生成。
3. java的语法糖
(1) 泛型
泛型是指对操作的数据类型指定为一个参数,参数化类型。Java中的泛型是通过类型擦除实现的,是一种伪泛型,它只在程序源码中存在,如果经过反编译之后可以看到是通过强制转换类型实现的。
(2) 自动装箱、拆箱
(2) 条件编译
第十一章:运行期优化
1. 即时编译器
即时编译器JIT:为了提高热点代码的运行效率,在运行时,虚拟机会把这些代码编译成本地机器码,提高运行效率,这就是即时编译器。
为什么大多数虚拟机采用解释器和编译器并存的架构?
解释器是对代码进行翻译再去执行,可以快速启动程序,省去编译时间,并节省内存,但运行效率较低;编译器是将代码编译成本地机器码在去执行,启动慢,空间占用大,但运行效率高。这两种结合可以满足更多的应用场景,此外,解释器可以作为编译器的逃生门,即编译器的激进优化失败的话返回解释器继续执行。
为什么虚拟机实现了C1、C2两个即时编译器?
首先虚拟机可以采用解释器和编译器搭配的混合模式、全部由解释器的解释模式、优先采用编译器(但在编译器无法进行的时候解释器介入)的编译模式,默认采用混合模式,是解释器和C1、C2中的一个搭配使用(C2编译器编译效果大于C1编译器)。众所周知,编译器编译后的代码执行效率高,除了省去了解释翻译的时间,更重要的虚拟机会采用优化措施,但是JIT编译需要占用程序运行的时间,优化程度不同占用时间也不同,而为了在程序启动响应速度和运行效率之间取平衡,虚拟机采用了分层编译的策略,根据编译器优化程度的不同,分为如下3层(分层编译C1和C2同时工作):
第0层:程序解释执行。
第1层:C1编译,将字节码编译为本地机器码,进行一些简单可靠的优化。
第2层:C2编译,将字节码编译为本地机器码,进行一些耗时,激进的优化。
什么时候触发JIT编译?
在运行期的热点代码会触发编译,热点代码有两种:多次调用的方法和多次执行的循环体(循环体的编译发生在方法的执行过程中,被称为栈上替换,即方法栈帧还在栈上,方法被替换了)。而判断“多次”也就是热点的判断称为热点探测,有两种方法:基于采样的热点探测和基于计数器的热点探测。补充一点,计数器的统计是统计一定时间内方法调用的频率,当超过一定的时间限定,会发生计数器热度衰减(计数器减少为一半)。
编译器的编译过程?
对于C1编译器,虚拟机采用的是快速简单的局部性优化;对于C2编译器,虚执行所有经典的优化动作,耗时长。
2. 几种经典的优化技术
1. 公共子表达式消除
例如a*b与b*a是一样的,虚拟机就会在计算过a*b后,对后面的a*b b*a都不进行运行,直接用前面计算的结果带入。
2. 数组边界检查消除
对于一个数组,每次对数组的读写都会进行判断有没有超过数组边界,但这种判断其实是可以消除的,如果确定指针范围在数组边界内,那么可以消除这个数据边界的检查。
3. 方法内联
方法内联是把目标方法的代码复制到发起调用的方法之中,避免真实的发生方法调用,对于一些方法比如 foo(Object obj){if(obj==null)return;}而调用的参数就是null,这其实是无用的方法调用。
4. 逃逸分析
逃逸分析的本质就是分析对象的动态作用域,例如一个对象在方法中被定义了之后,可能会当作另一个方法的参数被别的方法引用,这种称为方法逃逸;一个类变量可能会被不同的线程访问,称为线程逃逸。如果能够确定对象的作用域,分析其有没有逃逸发生,就可以采用一些比较激进高效的优化手段,例如下面三种:
栈上分配。一般的对象都是分配在堆上eden区的,但如果确定一个对象不会发生方法逃逸(不会逃逸的局部变量很多)就可以为他在栈上分配内存。
同步消除。如果确定一个变量不会发生线程逃逸,那么可以消除这个对象上的同步措施。
标量替换。Java中的int、long这种原始数据类型称为标量,对象这种是聚合量。如果对象确定不会发生逃逸,那么可以不创建对象而是将其成员变量替换为int、long这些标量。