深入理解JVM虚拟机

走近Java
自动内存管理机制
虚拟机执行子系统
程序编译与代码优化
高效并发

自动内存管理机制

2Java内存区域与内存溢出异常

1.JVM运行时数据区模型
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分成若干个不同的数据区域,这些数据区域都有各自的用途,以及创建和销毁时间,Java虚拟机所管理的内存将会包括以下几个运行时数区域

在这里插入图片描述
程序计数器
程序计数器是一块比较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时间,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空。次内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈
与程序计数器一样,Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、冬天链接、方法出口等信息,每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
经常有人把Java内存区分为堆内存和栈内存,这种分发比较粗糙,Java内存区域的划分实现上远比这个复杂。这种划分方式比较流行只能说明大多数程序员最关注的,与对象内存分配关系最密切的内存区域就是这两块。所指的“堆”在后面会讲到,所指的“栈”就是现在正在说的虚拟机栈,或者说是虚拟机栈中的局部变量表部分。
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、short、char、int、float、long、double)、对象引用(reference类型)和returnAddress类型。
其中64位长度的long和double类型的数据会占用两个局部变量空间,其余的数据类型只占用一个。局部变量表所需的内存空间在编译器以及完成分配,当进入一个方法时,这个方法所需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

本地方法栈
本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈位虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的Native方法服务。在虚拟机规范中堆本地方法栈中的方法使用的语言、使用的方式与数据结构没有强制的规定,因此具体的虚拟机可以自由实现它。

Java堆
对于大多数应用来说,Java堆是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。次内存区域的唯一目的就是存放对象顺利,几乎所有的对象实力都在这里进行分配。这一点Java虚拟机规范中的描述是:所有的对象实例以及数据都要在堆上分配,但是随着JIT编译器的发展和逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在多少也逐渐变得不是那么“绝对”了。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被成为”GC堆”。从内存回收的角度来看,由于现在的收集器基本上都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代;再细致一点可以分为Eden空间、From Survivor空间、To survivor空间等。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区,不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都依然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快的分配内存。在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

方法区
方法区与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做“非堆”,目的应该是与Java堆区分开来。
很多人都愿意把方法区称为永久代,本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。对于其他虚拟机,不存在永久代的概念。原则上,如何实现方法区是属于虚拟机的实现细节,不受虚拟机规范束缚。
Java虚拟机规范对方法区的限制非常的宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展意外,还可以选择不实现垃圾收集,相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和堆类型的卸载,一般来说,这个区域的回收确实是必要的。当方法区无法满足内存分配的需求时,将抛出OutOfMemoryError异常。

运行常量池
运行常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各个字面量和符号引用,这部门内容将在类加载后进入方法区的运行时常量池存放。
Java虚拟机堆Class文件每一部分的格式都有严格的规定,每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行,但对于运行时常量池,Java虚拟机规范每一个做任何细节上的要求,不同的供应商实现的虚拟机可以按照自己的要求来实现这个内存区域。不过,一般来说,除了保持Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行常量池中。
运行常量池相对于Class文件常量池的另外一个重要特性是具备动态性,Java语言并不要求常量一定只有在编译期才能生成,也就是并非预置入Class文件中的常量池的内容才能进入方法区运行时常量池,运行期间也可能将心的常量放入池中,这种特性被开发人员利用的比较多的就是String类的intern()方法。
常量池无法再申请到内存时会抛出OutOfMemoryError异常。

直接内存
直接内存不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分的内存也被频繁的使用,而且也可能导致内存溢出的异常。
在JDK1.4中加入了NIO类,引入了一种基于通道的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著的提高性能,因为避免了在Java堆和Native堆中来回复制数据。
显然,本机直接内存的分配不受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存大小以及处理器寻址空间的限制。

2.HotSpot虚拟机对象

创建对象
new
查找
类的符号引用(常量池)
(检查这个指令的参数是否能在常量池中定位到一个类的符号引用
检查符号引用代表的类是否已被加载、解析和初始化)
false-》类加载 第七章
true-》
虚拟机为新生对象分配内存(所需内存大小类加载后可完全确定)

  1. 如果Java堆规整,只需移动指针:指针碰撞(Bump the Pointer)
    Serial, ParNew等带压缩功能的垃圾收集器
  2. 如果不规整,维护列表记录哪些内存块可用:空闲列表(Free List);
    CMS等基于Mark-Sweep垃圾收集
    问题:并发情况下线程安全
    eg. 给对象A分配内存,指针没来得及修改,对象B使用原指针分配内存
  1. 同步分配内存空间的动作: CAS +失败重试保证(原子性)
  2. 本地线程分配缓冲(TLAB): 每个线程在Java堆中预先分配一块内存分
    配动作按照线程划分在不同空间。TLAB用完分配新的TLAB时同步锁定。

将分配到的内存空间初始化为零值(不包括对象头)/
使用TLAB时,提前至TLAB分配时进行。保证不赋初值就能直接使用

对象设置(类信息、对象哈希码、对象GC分代年龄等信息。存放于对象头。)
从虚拟机角度一个新的对象已经产生了

invokespecial指令

执行方法

代码清单1. HotSpot解释器的代码片段

//确保常量池中存放的是已解释的类
if(!constants->tag_at(index) .is_unresolved_klass()) {
	//断言确保是klassOop和instanceKlassOop(这部分下一节介绍)
	oop entry=(klassOop) *constants->obj_at_addr(index) ;
	assert(entry->is_klass(), "Should be resolved klass") ;
	klassOop k_entry=(klassOop) entry;
	assert(k_entry->klass_part()->oop_is_instance(), "Should be instanceKlass") ;
	instanceKlass * ik=(instanceKlass*) k_entry->klass_part();
	//确保对象所属类型已经经过初始化阶段
	if(ik->is_initialized()&&ik->can_be_fastpath_allocated()){
		//取对象长度
		size_t obj_size=ik->size_helper();
		oop result=NULL;
		//记录是否需要将对象所有字段置零值
		bool need_zero=!ZeroTLAB;
		//是否在TLAB中分配对象
		if(UseTLAB) {
			result=(oop) THREAD->tlab().allocate(obj_size) ;
		}
		if(result==NULL) {
			need_zero=true;
			//直接在eden中分配对象
			retry:
			HeapWord * compare_to=*Universe:heap()->top_addr();
			HeapWord * new_top=compare_to+obj_size;
			/*cmpxchg是x86中的CAS指令, 这里是一个C++方法, 通过CAS方式分配空间, 如果并发失败,转到retry中重试, 直至成功分配为止*/
			if(new_top<=*Universe:heap()->end_addr()) {
				if(Atomic:cmpxchg_ptr(new_top,Universe:heap()->top_addr(), compare_to) !=compare_to) {
					goto retry;
				}
				result=(oop) compare_to;
			}
		}
		if(result!=NULL) {
			//如果需要, 则为对象初始化零值
			if(need_zero) {
				HeapWord * to_zero=(HeapWord*) result+sizeof(oopDesc) /oopSize;
				obj_size-=sizeof(oopDesc) /oopSize;
				if(obj_size>0) {
					memset(to_zero, 0, obj_size * HeapWordSize) ;
				}
			}
			//根据是否启用偏向锁来设置对象头信息
			if(UseBiasedLocking) {
				result->set_mark(ik->prototype_header()) ;
			}else{
				result->set_mark(markOopDesc:prototype()) ;
			}
			result->set_klass_gap(0) ;
			result->set_klass(k_entry) ;
			//将对象引用入栈, 继续执行下一条指令
			SET_STACK_OBJECT(result, 0) ;
			UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1) ;
		}
	}
}

对象的内存布局
3部分:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。

对象头
– 第一部分,存储对象自身的运行时数据。(如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等)。这部分数据长度在32位和64位虚拟机中分别为32位和64位,官方称为"Mark Word"。
– 第二部分,类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针确定这个对象是哪个类的实例(数组还有一块记录数组长度)。
由于对象需要存储的运行时数据很多,考虑到虚拟机的内存使用,markOop被设计成一个非固定的数据结构,以便在极小的空间存储尽量多的数据,根据对象的状态复用自己的存储空间,32位虚拟机的markOop实现如下:
代码清单2. markOop.cpp片段

//Bit-format of an object header(most significant first,big endian layout below) :
//32 bits:
//--------
//hash:25------------>| age:4    biased_lock:1 lock:2(normal object)
//JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2(biased object)
//size:32------------------------------------------>|(CMS free block)
//PromotedObject*:29---------->| promo_bits:3----->|(CMS promoted object)

实例数据:对象真正存储的有效信息。也是在程序代码中所定义的各种类型的字段内容。无论是从父亲继承下来的还是在子类中定义的都需要记录起来。

对齐填充:仅起占位符的作用。HotSpot VM要求对象起始地址必须是8字节整数倍,换句话说,就是对象的大小必须是8字节的整数倍。当实例数据部分没有对齐时,通过对齐填充来补全。

对象的访问定位
java程序需要通过栈上的reference数据来操作堆上的具体对象。
1.使用句柄。java堆中会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体信息。
对象移动时只改变句柄中实例指针,reference不需要修改。
在这里插入图片描述
2.使用直接指针。java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,Reference中存储的直接就是对象地址。
速度快(节省一次指针定位的时间开销)。HotSpot使用此种方式。
在这里插入图片描述
3. 实战:OutOfMemoryError异常
除了程序计数器外,其他的几个运行时区域都有发生OOM异常的可能。

通过代码验证虚拟机规范中描述的各个运行时区域存储的内容;在遇到实际的内存溢出异常时,能根据异常的信息快读判断是哪个区域的内存溢出,知道什么代码可能会导致这些区域内存溢出,出现这些异常后该如何处理。

1)Java 堆溢出
Java堆用于存储对象实例,只要不断地创建对象,并保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量到达最大堆的容量限制后就会发生内存溢出异常
-Xms20m -Xmx20m 最小堆 最大堆20M设为为一样即可避免堆自动扩展
-XX:+HeapDumpOnOutOfMemoryError: 出现OOM时Dump出当前的内存堆转储快照以便时候进行分析
Java堆内存的OOM异常是实际应用中常见的内存溢出异常情况。当出现时,异常堆栈信息“java.kanf.OutOfMemoryError”会跟着进一步提升"Java heap space"。
解决:一先通过内存映像分析工具对dump出的堆转储快照进行分析,重点是确认内存中的对象是否是必要的,即先分清楚到底是出现了内存泄露还是内存溢出。内存泄露-》通过工具查看泄露对象到Gc Roots的引用链。于是就能找到泄露对象是通过怎样的路径与Gc Roots相关联并导致垃圾收集器无法自动回收他们的。 内存溢出-》就是内存中的对象却是都还必须存活着,检查虚拟机的堆参数-Xmx与-Xms,与机器物理内存对象看是否还可以调大,从代码上检查是否存在某些对象声明周期过长、持有状态时间过长,尝试减少程序运行期的内存消耗。
2)虚拟机栈和本地方法栈溢出
-Xss参数:设置栈容量。
StackOverflowError:如果线程请求的栈深度大于虚拟机所允许的最大深度。
OutOfMemoryError:如果虚拟机在扩展栈时无法申请到足够的内存空间。
当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大。
使用-Xss参数减少栈内存容量,抛出StackOverflowError异常,输出堆栈深度相应缩小。
定义了大量的本地变量,增大此方法帧中本地变量表的长度,抛出StackOverflowError异常,输出的堆栈深度相应缩小。
表明:单线程时,栈帧太大 or 虚拟机栈容量太小,当内存无法分配时,抛出StackOverflowError异常。
如果是建立过多线程导致内存溢出,但是这样产生的内存溢出异常与栈空间是否足够大并不存在任何联系,或者准确地说这种情况下,为每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。(原因:操作系统分配给每个进程的内存是有限制的,32位2GB,虚拟机提供了参数来控制Java堆和方法区的这两部分内存的最大值。剩余的内存为2Gb-Xmx最大堆容量-MaxPermSize最大方法区容量。如果虚拟机进程本身耗费的内容不计算在内,剩下的内存就由虚拟机栈和本地方法栈瓜分了。每个线程分配到的栈容量越大,可以建立的线程数量就越少,建立线程时就越容易把剩下的内存耗尽。)
建立过多线程导致的内存溢出,在不能减少线程数或更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。
3)方法区和运行时常量池溢出(PermGen Space)
JDK1.6前,运行时常量池分配在永久代中,-XX:PermSize和-XX:MaxPermSize限制方法区大小,间接限制其中常量池容量。抛出OutOfMemoryError:PermGen sapce异常,运行时常量池溢出,运行时常量池属于方法区(永久代)的一部分。
JDK1.6中,String.intern() 方法会把首次遇到的字符串实施复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由stringbuilder创建的字符串实例在java堆上,所以不是同一个引用,返回false。
JDK1.7,String.intern() 方法测试结果与JDK1.6不同。实现不会再复制实例,而是在常量池中记录首次出现的实例引用,因此返回true。
方法区用于存放class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述。对于这些区域的测试,基本的思路是产生大量类填满方法区,导致溢出。借助CGLib直接操作字节码运行时生成了大量的动态类。spring\hibernate在对类进行增强时,都会使用到GCLib这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的class可以加载入内存。JSP、OSGi等。
方法区溢出也是一种常见的内存溢出异常,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。在经常动态生成大量class的应用中,需要特别注意类的回收状况。这类场景除了上面提到的程序使用了GBLib字节码增强和动态语言之外,常见的还有:大量JSP或动态生成JSP文件的引用(JSP第一次运行时需要编译为Java类)、基于OSGi的应用(即使是同一个类文件,被不同的加载器加载也会视为不同的类)等。
4)本机直接内存溢出
DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,则默认与Java堆最大值(-Xmx指定)一样,直接通过反射获取unsafe实例进行内存分配(unsafe类的getunsafe()方法限制了只有引导类加载器才会返回实例,设计者希望只有rt.jar中的类才能使用unsafe的功能)。因为,虽然使用DirectByteBuffer分配内存也会抛出内存溢出的异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正申请分配内存的方法是unsafe.allocateMemory()。
DirectMemory导致的内存溢出,一个明显的特征是在heap dump文件中不会看见明显的异常,如果读者发现OOM之后Dump文件很小,而程序中又直接或间接使用了NIO,这就可以考虑检查一下是不是这方面的原因。使用NIO,可导致本机直接内存溢出。
直接内存溢出常常和NIO的使用有关,因为它会占用Java堆以外的内存。直接内存如果不指定默认和Java堆的最大值一样,可以通过使用Unsafe类进行直接内存的分配来验证OOM异常。

我们明白了虚拟机中的内存是如何划分的,哪部分区域、什么样的代码和操作可能导致内存溢出异常。虽然java有垃圾收集机制,但内存溢出异常离我们仍然并不遥远,本章只是讲解了各个区域出现内存溢出异常的原因。
第三章将详细讲解java垃圾回收机制为了避免内存溢出异常的出现都做了哪些努力。

3垃圾收集器与内存分配策略

概述
说起垃圾收集器,大部分人都把这项技术当做Java语言的伴生产物。事实上,GC的历史比Java更加悠久,Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言,一直以来,人们就在思考GC需要完成的3件事情:哪些内存需要回收?什么时候回收?如何回收?接下来我们就这三个问题进行展开,去了解一下Java的垃圾回收机制。虽然目前的内存的动态分配与内存回收技术已经是自动化的了,但是了解他们能够在需要排查各种内存溢出内存泄漏垃圾收集成为系统达到更高并发量的瓶颈时对这些技术实施必要的监控和调节。关注的是Jacva堆和方法区的内存回收。

哪些内存需要回收?
在堆中存放着Java世界中几乎所有的对象实例,垃圾回收器在对堆进行回收之前,第一件事情就是要确定这些对象中哪些还“存活”着,哪些已经“死去”,即不可能再被任何途径使用的对象,这些对象占用的内存就是我们需要进行回收的内存。
接下来来看一下Java虚拟机是如何判断一个对象是否需要回收。

引用计数法
很多教科书判断对象是否存活的算法是这样的:给对象添加一个引用计数器,每当有一个地方引用它,计数器数值就加1;当引用失效时,计数器数值就减1;任何时刻计数器为0的对象就是不可能再被使用的。客观地说,引用计数法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法,但是至少主流Java虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间循环引用的问题。
举一个简单的例子,一个类中有一个成员变量对象,创建两个该类的实例对象,让两个对象的成员变量互相引用,即objA.instance=objB.instance,除此之外,这两个对象再无其他的地方引用,实际上这两个对象已经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为0,于是引用计数算法无法通知GC收集器去回收它们。
但是实际上,JVM并不是通过引用技术算法来判断对象是否存活的。

可达性分析算法
在主流的商业程序语言的主流实现中,都是称通过可达性分析来判定对象是否存活的。这个算法的基本思路就是通过一系列的称之为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的,如下图所示,对象object5、object6、object7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可以回收的对象。
在这里插入图片描述

在Java语言中,可作为GC Roots的对象包括下面的几种:
1.虚拟机栈(栈帧中的本地变量表)中的引用对象
2.方法区中类静态属性引用的对象
3.方法区中常量引用的对象
4.本地方法栈中JNI(即一般说的Native方法)引用的对象

关于引用
Java对引用的概念进行了扩充,将引用划分为强引用、软引用、弱引用、虚引用这4种,这4种引用的强度依次减弱。
强引用就是指在程序代码中普遍存在的,类似“Object object = new Object();”这类的引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象。
软引用是用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围中进行第二次的回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用。
弱引用是用来描述非必需对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾回收之前。当垃圾回收开始时,无论当前内存是否足够,都会回收掉弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。
虚引用也称为幽灵引用或幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。

生存还是死亡
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可行性分析后发现没有与GC Roots相连接,那么它会被第一次标记且进行一次筛选,筛选的条件是此对象是否有必要执行finalize方法。当对象没有覆盖finalize方法,或者finalize方法已经被虚拟机调用过,虚拟机将这两种情况视为“没有必要执行”。
如果被判定有必要执行finalize方法的对象将被放置与F-Queue的队列中。并在稍后由一个虚拟机自动建立的、低优先级的finalize线程去执行它,这个执行并不会等待其运行结束,防止阻塞和崩溃。finalize方法是对象逃过死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize方法中拯救自己——只要重新与引用链上的任何一个对象建立关联即可,比如把自己this赋值给某个类变量或者对象的成员变量,那再第二次编辑时它被移除出即将回收集合,如果对象没有逃脱,就真的被回收。但是一个对象的finalize方法只能被执行一次。

回收方法区
方法区一般不回收,回收效率很低。在堆中,新生代的垃圾收集效率70-90%,而永生代的垃圾回收效率远低于此。
永生代的垃圾回收主要回收两部分内容:废弃常量和无用的类。“废弃常量”判断比较简单,没有任何一个string对象叫做"abc"没有任何string对象引用常量池中的这个常量,也没有任何地方引用了这个字面量。常量池中的其他类接口方法字段的符号引用也是类似。但是“无用的类”的判断复杂一些,需要满足下面3个条件:
1.该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
2.加载该类的ClassLoader已经被回收
3.该类对应的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading, -XX:+TraceClassUnLoading查看类的加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持。
在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证永久带不能溢出。

垃圾回收算法
标记-清除算法
算法分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,它的标记过程就是使用可达性算法进行标记的。
主要缺点有两个:
效率问题,标记和清除两个过程的效率都不高
空间问题:标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
在这里插入图片描述

复制算法
复制算法:将可用的内存按照容量分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后把已使用过的内存一次清理掉。
这样使得每次都是对着整个半区进行内存回收,内存分配时不用考虑内存碎片的问题,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。代价就是将内存缩小到原来的一半。
在这里插入图片描述

标记-整理算法
标记整理算法:标记过程仍然与“标记-清除”一样,但后续不走不是直接对可回收对象进行清理,而是让所有存活对象向一端移动,然后直接清理掉端边界以外的内存。
在这里插入图片描述

分代收集算法
根据对象存活周期的不同将内存分为几块。一般把Java堆分为新生代和老生代,根据各个年代的特点采用最合适的收集算法。在新生代中,每次垃圾收集时有大批的对象死去,只有少量的存活,可以选用复制算法。而老生代对象存活率最高,使用标记清除或标记整理算法。

HopSpot的算法实现
枚举根节点
GC进行时必须停顿所有的Java执行线程。即使是在号称不会停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到找到引用对象这个目的的。

安全点
实际上,HotSpot没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置称为安全点,即程序执行时并非在所有地方都能停顿下来开始GC,只有到达安全点时才可以暂停。SafePoint的选定既不能太少以至于让GC等待时间过长,也不能过于频繁以至于过分增大运行时的负载。所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准选定的——因为每条指令执行的时间都非常的短暂,程序不太可能因为流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生SafePoint。
由于GC时,需要所有线程在安全点中断,一种是抢占式中断;另一种是主动式中断,其中抢占式中断就是在GC发生的时候,首先把所有线程全部中断,如果发现有线程不在安全点,就恢复线程,让它跑到安全点上。现在几乎没有JVM采用这种方式来响应GC事件。而主动式中断的思想不是直接对线程操作,仅仅是简单设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的。

安全区域
有了安全点之后,也不能完美地解决GC的问题,但实际情况却不一定。当程序没有分配cpu时间,典型的例子就是线程处于sleep或者blocked状态,这个时候线程无法响应JVM的中断请求,“走”到安全点挂起。对于这种情况,就需要安全区域来解决。
安全区域是指在一段代码中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的,我们也可以把Safe Region看作是被扩展的SafePoint。
关于内存回收,如何进行是由JVM所采用的GC收集器来决定的。而通常JVM中往往不止有一种GC收集器。接下来来一起看一下HotSpot虚拟机的GC收集器。

垃圾收集器
在这里插入图片描述
新生代:Serial ParNew Parallel Scavenge

————————————————G1————

老年代:CMS Serial Old Parallel Old

Serial Collector
Serial收集器是单线程收集器,是分代收集器。它进行垃圾回收时,必须暂停其他所有的工作线程,直到它收集结束。
新生代:单线程复制收集算法;老年代:单线程标记整理算法
Serial一般在单核的机器上使用,是Java5非服务端JVM的默认收集器,参数-XX:UseSerialGC设置使用。
历史悠久的收集器,采用复制算法的新生代收集器
完全单线程,收集时会停止到其他的线程(“Stop The World”)
注意:之后发展的收集器也不能完全消除暂停线程,只能不断缩短暂停的时间
它是虚拟机在运行在Client模式下的默认新生代收集器

ParNew收集器
Serial收集器的多线程版本
除Serial外,只有他能够CMS收集器配合(不幸的是,JDK1.5提出的CMS作为老年代的收集器,却无法与JDK1.4中已经存在的Parallel Scavenge配合工作)
在单核环境下,性能不会超过Serial收集器
默认开启的收集线程和CPU的数量一样多,也可以通过参数限制线程数
并行:指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态
并发:指用户线程与垃圾收集线程同时执行,用户程序在继续执行,而垃圾收集程序运行于另一个CPU上

Parallel Scavenge收集器
一个新生代收集器,使用复制算法的收集器,又是并行的多线程收集器。目标是达到一个可控制的吞吐量。
新生代收集器,也是采用复制算法,JDK1.4中已经存在
它的目标是达到一个可控制的吞吐量,所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值
停顿时间短—>适合需要与用户交互的程序,响应快;高吞吐量–>可以高效率的利用CPU,适合后台运算而不需要太多交互的任务
相关控制参数:
-XX:MaxGCPauseMillis:控制停顿时间,注意GC停顿时间越短,吞吐量越小,新生代的空间越需要的越多
-XX:GCTimeRatio:控制垃圾收集时间占总时间的比率(比如该值为19则,GC时间占比1/20),相当于(约等)吞吐量的倒数
-XX:UseAdaptiveSizePolicy:打开后不用手动同时指定上面两个参数(可以指定单个),收集器会自适应改变上面两个参数

Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同样是单线程的,使用“标记-整理”算法。
是Serial收集器的老年版,使用标记-整理算法
在Server模式下,它主要有两个作用:
在JDK1.5以及之前的版本与Parallel Scavenge收集器搭配使用
作为CMS收集器的后备预案

Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK1.6中才开始提供的。
Parallel Old是Parallel Scavenge收集器的老年代版本,采用多线程和复制整理算法,JDK1.6中才开始提供的
它出来之前,除了Serial Old外,PS收集器别无其他可以合作的老年代收集器

CMS收集器
也称”low-latency collector”,为了解决老年代暂停时间过长的问题,并且真正实现并行收集(程序与GC并行执行)。是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是基于“标记-清除”算法来实现的。
新生代:收集和Parallel Collector新生代收集方式一致。
老年代:GC与程序同时进行。
分为四个阶段:
1、初始标记:暂停一会,找出所有活着对象的初始集合。
2、并行标记:根据初始集合,标记出所有的存活对象,由于程序在运行,一部分存活对象无法标记出。此过程标记操作和程序同事执行。
3、重新标记:程序暂停一会,多线程进行重新标记所有在2中没有标记的存活对象。
4、并行清理:回收所有被标记的垃圾区域。和程序同时进行。

在这里插入图片描述
由于此收集器在remark阶段重新访问对象,因此开销有所增加。
此收集器的不足是,老年代收集采用标记清除算法,因此会产生很多不连续的内存碎片。
此收集器一般多用于对程序暂停时间要求更短的程序中,多用于web应用(实时性要求很高)。可以通过参数-XX:+UseConcMarkSweepGC设置使用它。
以获取最短回收停顿时间为目标的收集器,看重服务的响应速度,采用标记-清除算法,收集的过程分为4个过程:
初始标记:仅标记GC Roots能直接关联的对象
并发标记:并发进行GC Roots Tracing
重新标记:修正并发标记期间因程序的继续运行产生的变动
并发清除:
初始标记、重新标记仍需要“Stop The World”;并发标记、并发清除时间耗时最长
缺点:
CMS收集器对CPU资源非常敏感,CPU个数越少,CMS对用户程序的影响就可能变得很大
CMS收集器无法处理浮动垃圾:并发标记时新产生的垃圾只能在下一次清理,因此,CMS收集器不能像其他老年代收集器在老年代几乎填满了在进行收集,可以通过参数来设置触发比。如果CMS期间内存不够用,将会临时启用Serial Old收集器重新收集
采用标记-清除算法,因此会有空间碎片产生,如果无法找到足够大的的连续空间来分配对象,会提前触发Full GC。提供了一个参数来打开在Full GC之前进行空间整理

G1收集器
G1收集器是当今收集技术发展的最前沿成果之一。G1是一款面向服务端应用的垃圾收集器。HotSpot开发团队赋予它的使命是在未来替换CMS收集器。
它具有以下几个特点:
并行与并发:G1能充分利用多CPU、多核环境下的硬件优势
分代收集
空间整合:基于“标记-整理”算法实现的收集器
可预测的停顿:这是G1相对于CMS的另一大优势
当今发展最前沿的成果之一,JDK1.7提供,它是面向服务端应用的垃圾收集器
G1能充分利用多CPU,缩短StopTheWorld的时间
G1也是能分代收集的,虽然它能管理整个堆。它能够采用不同的方式处理新生代和老年代对象
G1从整理上看是标记整理算法,从局部上看是复制算法
能够预测停顿
G1将内存划分为多个Region,新生代和老生代不在是物理隔离,按照Region回收价值最大的先回收策略
需要处理的问题:
多个Region会互相关联的引用,怎么来避免全部扫描堆内存:采用Remembered Set来避免

理解GC日志
注意点:
GC发生的时间,从虚拟机启动以来经过的秒数
垃圾收集的停顿类型Full GC(会StopTheWorld)还是Minor GC。(如果是调用system.gc()方法触发的收集会显示full GC(system))
GC发生的区域(DefNew Tenured Perm ParNew)这里显示的区域名称与使用的GC收集器是密切相关的。
如:Serial收集器的新生代名为Default New Generation显示DefNew
ParNew收集器,新生代名为ParNew,parallel New Generation
Parallel Scavenge收集器,它配套的新生代称为PSYoungGen
GC前和后的内存
内存区域GC所占用的时间(user sys real用户态消耗的CPU时间、内核态消耗的CPU时间)

垃圾收集器参数总结
参数 描述
-XX:+UseSerialGCJvm运行在Client模式下的默认值,打开此开关后,使用Serial + Serial Old的收集器组合进行内存回收
-XX:+UseParNewGC 打开此开关后,使用ParNew + Serial Old的收集器进行垃圾回收
-XX:+UseConcMarkSweepGC 使用ParNew + CMS + Serial Old的收集器组合进行内存回收,Serial Old作为CMS出现“Concurrent Mode Failure”失败后的后备收集器使用。
-XX:+UseParallelGC Jvm运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge + Serial Old的收集器组合进行回收
-XX:+UseParallelOldGC 使用Parallel Scavenge + Parallel Old的收集器组合进行回收
-XX:SurvivorRatio 新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden:Subrvivor = 8:1
-XX:PretenureSizeThreshold 直接晋升到老年代对象的大小,设置这个参数后,大于这个参数的对象将直接在老年代分配
-XX:MaxTenuringThreshold 晋升到老年代的对象年龄,每次Minor GC之后,年龄就加1,当超过这个参数的值时进入老年代
-XX:UseAdaptiveSizePolicy 动态调整java堆中各个区域的大小以及进入老年代的年龄
-XX:+HandlePromotionFailure 是否允许新生代收集担保,进行一次minor gc后, 另一块Survivor空间不足时,将直接会在老年代中保留
-XX:ParallelGCThreads 设置并行GC进行内存回收的线程数
-XX:GCTimeRatio GC时间占总时间的比列,默认值为99,即允许1%的GC时间,仅在使用Parallel Scavenge 收集器时有效
-XX:MaxGCPauseMillis 设置GC的最大停顿时间,在Parallel Scavenge 收集器下有效
-XX:CMSInitiatingOccupancyFraction 设置CMS收集器在老年代空间被使用多少后出发垃圾收集,默认值为68%,仅在CMS收集器时有效,-XX:CMSInitiatingOccupancyFraction=70
-XX:+UseCMSCompactAtFullCollection 由于CMS收集器会产生碎片,此参数设置在垃圾收集器后是否需要一次内存碎片整理过程,仅在CMS收集器时有效
-XX:+CMSFullGCBeforeCompaction 设置CMS收集器在进行若干次垃圾收集后再进行一次内存碎片整理过程,通常与UseCMSCompactAtFullCollection参数一起使用
-XX:+UseFastAccessorMethods 原始类型优化
-XX:+DisableExplicitGC 是否关闭手动System.gc
-XX:+CMSParallelRemarkEnabled 降低标记停顿
-XX:LargePageSizeInBytes 内存页的大小不可设置过大,会影响Perm的大小,-XX:LargePageSizeInBytes=128m

内存分配与回收策略
-Xms20M -Xmx20M -Xmn10M java堆大小为20M,不可扩展,10M分配给新生代,10M给老年代。-XX:+PrintFCDetails -XX:SurvivorRatio=8决定了新生代中Eden区与一个survivor区的空间比例是8:1。
对象的内存分配,往大方向讲,就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。在实际应用中,内存回收日志一般是打印到文件后通过日志工具进行分析。
Minor GC与Full GC有什么不一样吗?
新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多数都具备朝生夕死的特性,所以Minor GC非常频繁,一般回收速度也比较快。
老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC速度一般会比Minor GC慢10倍以上。

大对象直接进入老年代
所谓的大对象是指,需要大量的连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说就是一个坏消息(替Java虚拟机抱怨一句,比遇见一个大对象更加坏的消息就是遇到一群“朝生夕死”的短命大对象,写程序时应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来放置它们。
虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survior区之间发生大量的内存复制。

长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别出哪些对象应该放在新生代,哪些对象应该放在老年代中。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survior容纳的话,将被移动到Surivor空间中,并且对象年龄设为1。对象在Survior区中每“熬过”一次Minor GC,年龄就增加1,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。

动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能够晋升老年代,如果在Surivor空间中相同年龄所有对象大小的总和大于Surivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果这个条件成立,那么Minor GC可用确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。
下面解释一下“冒险”是冒了什么险,前面提到过,新生代使用复制收集算法,但为了内存使用率,只使用其中一个Surivor空间来作为轮换备份,因此出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收新生代中所有对象都存活),就需要老年代进行分配担保,把Survior无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代如果要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多的空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败。如果出现了担保失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但是大部分的情况下还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。
在Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的
如果不成立,则虚拟机会查看是否允许担保失败
如果允许,虚拟机会检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于会尝试着进行一次Minor GC
如果小于或者不允许冒险,那么这时也要改为进行一次Full GC
担保失败也会触发Full GC

本章介绍了垃圾收集的算法
垃圾收集器特点运行原理
虚拟机中自动内存分配及回收的主要规则

调优方法

4虚拟机性能监控与故障处理工具

数据:运行日志、异常堆栈、GC日志、线程快照、堆转储快照
可以直接在应用程序中实现功能强大的监控分析功能。

JDK命令行工具
主要有:
jps:JVM process Status Tool 虚拟机进程状况工具
jstat:JVM Statistics Monitoring Tool虚拟机统计信息工具:类装载、内存、垃圾收集、JIT编译等数据
jinfo:Configuration Info for Java Java配置信息工具:实时地查看和调整虚拟机各项参数
jmap:Memory Map for Java Java内存映射工具:用于生成堆转储快照
jhat:JVM Heap Dump Brower 虚拟机堆转储快照分析工具
jstack:Stack Trace for Java Java堆栈跟踪工具:生成线程快照

HSDIS:JIT生成代码反汇编
JDK的可视化工具
JConsole和VisualVM

Integer.valueOf会缓存[-128,127]的整数

jps
jps是jdk提供的一个查看当前java进程的小工具。输出JVM中运行的进程状态信息。
语法格式如下:
jps [options] [hostid]

                  如果不指定hostid就默认为当前主机或服务器。
                    命令行参数选项说明如下:
                   -q 不输出类名、Jar名和传入main方法的参数
                   -m 输出传入main方法的参数
                    -l 输出main类或Jar的全限名
                   -v 输出传入JVM的参数

常用命令:
jps –l:输出主类或者jar的完全路径名

jstack
找出最耗时的java线程,据此分析代码
步骤
1.ps -ef | grep java(或者输入服务的名称)
2.top -Hp pid1(pid1为需要分析的进程)
3.找到pid1中最耗时的线程ID pid2;将pid2输出16进制,因为jstack获取进程是将java线程Id用16进制输出ID。printf “%x\n” pid2
4.jstack pid1 | grep -A 10 pid2。 -A 10表示查找到所在行的后10行
jstat(JVM Statistics Monitoring Tool)是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据,在没有GUI图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首选工具。
jstat命令格式为:jstat [ option vmid [ interval [ s | ms ] [count] ] ]
对于命令行格式中的VMID与LVMID:如果是本地虚拟机进程,VMID与LVMID是一致的,如果是远程虚拟机进程,那VMID的格式应当是:
[protocol:] [//] lvmid [@hostname [:port]/servername]
参数interval和count代表查询间隔和次数,说明只查询一次。如假设需要每个250毫秒去查询一次进程768垃圾收集状况,一共查询了20次,那命令应当是:
jstat -gc 768 250 20
在这里插入图片描述

找出死锁问题
使用jps查看线程ID
使用jstack ID:查看线程情况

jinfo:Java配置信息工具
jinfo(Configuration Info for Java) 的作用是实时地查看和调整虚拟机各项参数。

jmapJava内存映像工具
jmap(Memory Map for Java)命令用于生成堆转储快照(一般称为heapdump或dump文件)。
jmap的作用并不仅仅是为了获取dump文件,它还可以查询finalize执行队列、Java堆和永久代的详细信息,如空间使用率、当前用的那种收集器等。
jmap命令格式
jmap [options] vmid
jmap -heap pid 展示pid的整体堆信息
在用cms gc的情况下,执行jmap -heap有些时候会导致进程变T,因此强烈建议别执行这个命令,如果想获取内存目前每个区域的使用状况,可通过jstat -gc或jstat -gccapacity来拿到。
jmap -histo pid 展示class的内存情况
说明:instances(实例数)、bytes(大小)、classs name(类名)。它基本是按照使用使用大小逆序排列的。
jmap -histo:live pid>a.log
可以观察heap中所有对象的情况(heap中所有生存的对象的情况)。包括对象数量和所占空间大小。可以将其保存到文本中去,在一段时间后,使用文本对比工具,可以对比出GC回收了哪些对象。jmap -histo:live这个命令执行,JVM会先触发gc,然后再统计信息。
dump 将内存使用的详细情况输出到文件
jmap -dump:live,format=b,file=a.log pid
将内存信息dump到a.log文件中。这个命令执行,JVM会将整个heap的信息dump写入到一个文件,heap如果比较大的话,就会导致这个过程比较耗时,并且执行的过程中为了保证dump的信息是可靠的,所以会暂停应用。
在这里插入图片描述

jhat虚拟机堆转储快照分析工具
Sun JDK提供jhat(JVM Heap Analysis Tool)命令与jmap搭配使用,来分析jmap生成的堆转储快照。
但一般不推荐使用,一是不会在应用服务器上直接分析dump文件,耗时且吃硬件资源,二是jhat的分析功能相对简陋。
1.导出堆
jmap -dump:live,file=a.log pid
2.分析堆文件
jhat -J-Xmx512M a1.log
说明:有时dump出来的堆很大,在启动时会报堆空间不足的错误,可加参数:jhat -J-Xmx512m 。这个内存大小可根据自己电脑进行设置。
3.查看html http://ip:7000/

jstat Java堆栈跟踪工具
jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或javacore文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的就是定位线程出现长时间停顿的原因,如线程间死锁、死锁循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的常见原因。线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就是知道没有响应的线程到底在后台做些什么事情,或者等待什么资源。
jstack命令格式:
jstack [options] vmid
如jstack -l 547。options选项如下:
在这里插入图片描述
jstat -class pid(监视类装载、卸载数量、总空间以及耗费的时间)
Loaded : 加载class的数量
Bytes : class字节大小
Unloaded : 未加载class的数量
Bytes :未加载class的字节大小
Time : 加载时间
jstat -gc pid(垃圾回收堆的行为统计,常用命令)
C即Capacity 总容量
U即Used 已使用的容量
S0C : survivor0区的总容量
S1C : survivor1区的总容量
S0U : survivor0区已使用的容量
S1C : survivor1区已使用的容量
EC : Eden区的总容量
EU :Eden区已使用的容量
OC : Old区的总容量 OU :
Old区已使用的容量 PC : 当前perm的容量 (KB)
PU :perm的使用 (KB)
YGC : 新生代垃圾回收次数
YGCT : 新生代垃圾回收时间
FGC : 老年代垃圾回收次数
FGCT :老年代垃圾回收时间
GCT : 垃圾回收总消耗时间
-gcutil(同-gc,输出的是已使用空间占总空间的百分比)
-gccause(垃圾收集统计概述(同-gcutil),附加最近两次垃圾回收事件的原因)

jconsole
jconsole.exe ip:port
Java件事与管理控制台
JDK的可视化工具
JConsole(Java Monitoring and Management Console)是一种基于JMX的可视化监视、管理工具。通过JDK/bin目录下的“jconsole.exe”启动。
VisuaIVM(All-in-one Java Troubleshooting Tool)是目前功能最强大的运行监视和故障处理程序,基于NetBeans平台开发,具备了插件扩展功能的特性,通过扩展扩展支持,VisuaIVM可以做到:
(1)显示虚拟机进程以及进程的配置、环境信息(jps,jinfo)
(2)监视应用程序的CPU、GC、堆、方法区以及线程的信息(jstat、jstack)
(3)dump以及分析堆转储快照(jmap、jhat)
(4)方法级的程序运行性能分析,找出被调用最多、运行时间最长的方法
(5)离线程序快照:收集程序的运行时配置、线程dump、内存dump等信息建立一个快照,可以将快照发送开发者处进行Bug反馈
(6)其它plugins的无限的可能性

javap
javap -c xxx.class
输出的内容就是字节码。
https://www.cnblogs.com/kongzhongqijing/tag/jvm/

综合应用场景
OOM解决方案:
1.首先配置JVM启动参数,让JVM在遇到OutOfMemoryError时自动生成Dump文件。
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path
2.jmap -dump:format=b,file=/path/heap.bin 进程ID 如果只dump heap中的存活对象,则加上选项-live。
3.后使用eclipse的mat插件或jhat命令。 用MAT打开这个文件heap.bin,利用现成的OOM工具进行分析。 用jhat命令可以参看 jhat -port 5000 heapDump 在浏览器中访问:http://localhost:5000/
查看详细信息

性能问题查找解决方案
1.发现问题:
1.1使用uptime命令查看CPU的Load情况,Load越高说明问题越严重;
1.2 使用jstat查看FGC发生的频率及FGC所花费的时间,FGC发生的频率越快、花费的时间越高,问题越严重;
2.在应用快要发生FGC的时候把堆导出来:
2.1 执行jstat -gc,将输出包括了新生代、老年代及持久代的当前使用情况,如果不停的重复上面的命令,会看到这些数字的变化,变化越大说明系统存在问题的可能性越大。当看到使用率为99%(具体数值可以设置)或快接近的时候,就立即可以执行导出堆栈的操作了。
2.2 将数据导出: jmap -dump:format=b,file=heap.bin pid 。这个时候会在当前目录以生成一个heap.bin这个二进制文件。
3.通过命令查看大对象
查看对象数最多的对象,并按降序排序输出:
执行:jmap -histo |grep alibaba|sort -k 2 -g -r|less
查看占用内存最多的最象,并按降序排序输出:
执行:jmap -histo |grep alibaba|sort -k 3 -g -r|less
4.数据分析
这个时候将dump出的文件在ECLIPSE中打开,使用MAT进行分析(ECLIPSE需要先安装MAT插件)
5.优化
优化的思路就是上面所列出来的问题,查看实现代码中所存在问题,具体问题具体分析。

频繁GC问题或内存溢出问题
1.使用jps查看线程ID
2.使用jstat -gc pid 250 20 查看GC的增长情况
3.使用jstat -gccause:额外输出上次GC原因
4.使用jmap -dump:format=b,file=heapDump 3331生成堆转储文件
5.使用jhat或者可视化工具(Eclipse Memory Analyzer 、IBM HeapAnalyzer)分析堆情况。
6.结合代码解决内存溢出或泄露问题。

5调优案例分析与实战

高性能硬件上的程序部署策略
问题:高性能硬件上的超大堆内存,Full GC能有十几秒,会造成服务停顿。
如果是通过64位JDK使用大内存的缺点:
大内存GC停顿时间长,64位JDK没有32位快,如果仍溢出,dump出的堆转储快照很大无法分析,64JDK消耗较大(指针膨胀,数据类型对齐等造成)
解决办法:使用若干个32位虚拟机建立逻辑集群来利用硬件资源(无Session复制的亲合式集群),但可能会遇到的问题:
尽量避免节点竞争全局的资源
很难最高效地利用某些资源池
各个节点仍面临32位的内存的限制
大量使用本地缓存,比如HashMap缓存导致较大的内存浪费

集群间同步导致的内存溢出
问题:一个BS系统,采用集群部署,需要各个节点共享数据,不定期出现内存泄漏
原因:使用JBossCache构建全局缓存,会向所有节点同步操作时间,导致网络交互繁忙,从而会导致消息重发,大量的重发消息会在内存缓存,从而导致OOM

堆外内存导致的溢出错误
问题:使用NIO导致直接内存溢出
引申出类似的非常见非堆内存过大问题:
Directr Memory:可以通过参数控制大小
线程堆栈:可以通过参数控制大小
Socket缓冲区:每个Socket连接都有接收和发送缓存,可能会导致溢出
JNI代码:本地内存也不再堆中,可能会溢出
虚拟机和GC:虚拟机和GC的代码也要消耗一定的内存,因此需要预留一定的空间

外部命令导致系统缓慢
问题:java调动shell命令,会克隆线程导致大量占用CPU资源
解决:使用Java API实现

服务器JVM进程崩溃
问题:出现集群虚拟机自动关闭的情况
原因:异步任务返回时间过长导致Socket连接越来越多,最终是JVM崩溃

不恰当的数据结构导致内存占用过大
问题:在内存中加载大数据会造成GC长时间停顿
解决:考虑将Survivor空间去掉,大数据直接进入老年代

Windows虚拟内存导致的常见停顿
问题:准备开始GC到开始GC之间消耗了大部分时间
原因:GUI程序在最小化的时候,工作内存被自动交换到磁盘的页面文件之中了,发生GC时就有可能因为恢复页面文件的操作导致不正常的GC停顿

第三部分 虚拟机执行子系统

6类文件结构

概述
代码编译的结果是从本地机器码转变为字节码,是存储格式发展的一小步,确是编程语言发展的一大步。我们都只带Java是一门跨平台的语言,其在诞生之初,就提出了一个著名的口号:“一次编写,处处运行”,Sun公司以及其他虚拟机提供商发布了许多可以运行在各种不同平台上的虚拟机,各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码是构成平台无关性的基石。二实现语言无关性的基础仍然是虚拟机与字节码存储格式,Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,本章,我们就来看一下“Class文件”的组成奥秘。

Class类文件的结构
解析Class文件的数据结构是本章的主要内容。Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件中,中间没有添加任何的分隔符,这使得整个Class文件中的存储内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储。
根据Java虚拟机规范的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结果中只有两种数据类型:无符号数和表,后面的解析都要以这两种数据类型为基础,所以这里要先介绍这两个概念。
无符号数数据基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地一“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表,它由下图的表格中的数据项构成。
在这里插入图片描述

无论是无符号数还是表,当需要描述同一个类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项形式,这时称这一系列连续的某一类型的数据为某一类型的集合。
Class的结构不像XML等描述语言,由于它没有任何分割符号,所以在上图中的数据项,无论是顺序还是输了,甚至于数据存储的字节序这样的细节,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。接下来我们将一起看看这个表中各个数据项的具体含义。

魔数与Class文件的版本
每个Class文件的头四个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。很多文件存储标准中都使用魔数来进行身份识别,譬如图片格式,如gif或者jpeg等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意的改动。文件格式的制定者可以自由地选择魔数值,只要这个魔术值还没有被广泛采用过同时又不会引起混淆即可。
紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号(Minor Version),第7和第8个字节是主版本号(Major Version)。Java的版本号是从45开始的,JDK1.1之后的每个JDK大版本发布主版本号向上加1(JDK1.01.1使用了45.045.3的版本号),高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,及时文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。
例如:JDK1.1能支持版本号为45.045.65535的Class文件,无法执行版本号为46.0以上的Class文件,而JDK1.2则能支持45.046.65535的Class文件。现在,最新的JDK版本为1.8,可生成的Class文件主版本号最大值为52.0。
下图列出了从JDK1.1到JDK1.7,主流JDK版本表一起输出的默认和可支持的Class文件版本号。
在这里插入图片描述

常量池
紧接着主次版本号之后的是常量池入口,常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目。
由于常量池中的常量数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数器。与Java中语言习惯不一样的是,这个容量计数是从1而不是0开始的,常量池容量为十六进制数0x0016,即十进制的22,这就代表常量池中有21项常量,索引值范围为1~21.在Class文件格式规范制定之时,设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况就可以吧索引值置为0来表示。Class文件结构中只有常量池的容量计数是从1开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都一般习惯相同,是从0开始的。
常量池中主要存放两大类常量:字面量和符号引用。字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括了下面三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符
    Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。

访问标志
在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等。

类索引、父类索引与接口索引集合
类索引和父类索引都是一个u2类型的数据,而接口索引集合是一组u2类型的数据的集合,Class文件中有这三项数据来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0.接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果这个类本身是一个接口,则应当是extends语句)后的接口顺序从左到右排列在接口索引集合中。
类索引、父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引用两个u2类型的索引值表示,它们各自指向一个类型为CONSTANT_Class_info的类描述符常量,通过CONSTANT_Class_info类型的常量中的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串,下图演示了类索引查找的过程。
在这里插入图片描述
对于接口索引集合,入口的第一项——u2类型的数据为接口计数器,表示索引表的容量。如果该类没有实现任何接口,则该计数器值为0,后面接口的索引表不再占用任何字节。

字段表集合
字段表用于描述接口或者类中声明的变量。字段包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。我们可以想一想在Java中描述一个字段可以包含什么信息?可以包括的信息有:字段的作用域(private、protect、public修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制主从内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、字段名称。上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很合适使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。
在这里插入图片描述
字段修饰符放在access_flags项目中,它与类中的access_flags项目是非常相似的,都是一个u2的数据类型,其中可以设置的标志位和含义见下图
在这里插入图片描述
很明显,在实际情况中,ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED三个标志做多只能选择其一,ACC_FINAL、ACC_VOLATILE不能同时选择。接口之中的字段必须有ACC_PUBLIC、ACC_STATIC、ACC_FINAL标志,这些都是由Java本身的语言规则所决定的。
跟随access_flags标志的是两项索引值:name_index和descriptor_index。它们都是对常量池的引用,分别代表着字段的简单名称和方法的描述符。现在需要解释一下“简单名称”、“描述符”以及前面多次出现过的“全限定名”这三种特殊字符串的概念。
全限定名和简单名称很好理解,例如:“org/test/clazz/TestClass”是这个类的全限定名,仅仅是把类全名中的“.”替换成了“/”而已,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”表示全限定名结束。简单名称是指没有类型和参数修饰的方法或者字段名称,这个类中的inc()方法和m字段的简单名称分为是“inc”和“m”。
相对于全限定名和简单名称来说,方法和字段的描述符就要复杂一些。描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示。
在这里插入图片描述
对于数组类型,每一维度将使用一个前置的“[”字符来描述,如一个定义为“java.lang.String[][]”类型的二维数组,将被记录为:“[[Ljava/lang/String”,一个整型数组“int[]”将被记录为“[I”。
用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。如方法void inc()的描述符为“()V”,方法java.lang.String toString()的描述符为“()Ljava/lang/String;”,方法int indexOf(char[] source, int sourceOffset, int sourceCount, char[] target, int targetOffset, int targetCount, int fromIndex)的描述符为“([CII[CIII)I”。
字段表都包含的固定数据项目到descriptor_index为止就结束了,不过在descriptor_index之后跟随着一个属性表集合用于存储一些额外的信息,字段都可以在属性表中描述零至多项的额外信息。
字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段。譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。另外,在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的。

方法表集合
Class文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式,方法表的结构如同字段表一样,依次包括了访问标志(access_flag)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)几项。这些数据项目的含义也非常类似,仅在访问标志和属性表集合的可选项中有所区别。
在这里插入图片描述
因为volatile关键字和transient关键字不能修饰方法,所以方法表的访问标志中没有了ACC_VOLATILE标志和ACC_TRANSIENT标志。与之相对的,synchronized、native、strictfp和abstract关键字可以修饰方法,所以方法表的访问标志中增加了ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRICTFP和ACC_ABSTRACT标志。对于方法表,所有标志位及其取值见下图。
在这里插入图片描述
在这里,可能你会有疑问,方法的定义可以通过访问标志、名称索引、描述符索引表达清楚,但方法里面的代码哪去了?方法里的Java代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性里面,属性表作为Class文件格式中最具有扩展性的一种数据项目,将在后续文章介绍。
与字段表集合相对应的,如果父类方法在子类中没有被重写,方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器“”方法和实例构造器“”方法。
在Java语言中,要重载一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名中,因此Java语言里面是无法仅靠返回值的不同来对一个已有方法进行重载的。但是在Class文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法也可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个Class文件中的。

属性表集合
属性表在前面的讲解中已经出现过多次,在Class文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。
与Class文件中其他的数据项目要求严格的顺序、长度、和内容不同,属性表集合的限制稍微放宽了一些,不再要求各个属性表具有严格的顺序,并且只要不予已有属性表名重复,任何人实现的编译器都可以项属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识的属性。为了能正确解析Class文件,《Java虚拟机规范》中预定义属性已经增加到了21项,,具体见下表。
属性名称 使用位置 含义
Code 方法表 Java代码编译成的字节码指令
ConstantValue 字段表 final关键字定义的常量值
Deprecated 类、方法表、字段表 被声明为deprecated的方法和字段
Exceptions 方法表 方法抛出的异常
EnclosingMethod 类文件 仅当一个类为局部类或者匿名类时才能拥有这个属性,这个属性用于标识这个类所在的外围方法
InnerClasses 类文件 内部类列表
LineNumberTable Code属性 Java源码的行号与字节码指令的对应关系
LocalVariableTable Code属性 方法的局部变量描述
StackMapTable Code属性 JDK1.6中增加的属性,供新的类型检查验证器检查和处理目标方法的局部变量和操作数栈所需要的类型是否匹配
Signature 类、方法表、字段表 JDK1.5中新增的属性,这个属性用于支持泛型情况下的方法签名,在Java语言中,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量或参数化类型,则Signature属性会为它记录泛型签名信息。由于Java的泛型采用擦除法实现,是为了避免类型信息被擦除后导致签名混乱,需要这个属性记录泛型中的信息
SourceFile 类文件 记录源文件名称
SourceDebugExtension 类文件 JDK1.6中新增的属性,SourceDebugExtension属性用于存储额外的调试信息。譬如在进行JSP文件调试时,无法通过Java堆栈来定位到JSP的行号,JSR-45规范为这些非Java虚拟机中的程序提供了一个进行调试的标准机制,使用SourceDebugExtension属性就可以用于存储这个标准所新加入的调试信息
Synthetic 类、方法表、字段表 标识方法或字段为编译器自动生成的
LocalVariableTypeTable 类文件 JDK1.5中新增的属性,它使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加
RuntimeVisibleAnnotations 类、方法表、字段表 JDK1.5中新增的属性,为动态注解提供支持。RuntimeVisibleAnnotations属性用于指明哪些注解是运行时(实际上运行时就说进行反射调用)可见的
RuntimeInvisibleAnnotations 类、方法表、字段表 JDK1.5中新增的属性,作用与RuntimeVisibleAnnotations属性刚好相反,用于指明哪些注解是运行时不可见的
RuntimeVisibleParameterAnnotations 方法表 JDK1.5中新增的属性,作用与RuntimeVisibleAnnotations属性相似,只不过作用对象为方法参数
RuntimeInvisibleParameterAnnotations 方法表 JDK1.5中新增的属性,作用与RuntimeInvisibleAnnotations属性相似,只不过作用对象为方法参数
AnnotationDefault 方法表 JDK1.5中新增的属性,用于记录注解类元素的默认值
BootstrapMethods 类文件 JDK1.7中新增的属性,用于保存invokedynamic指令引用的引导方法限定符
对于每个属性,它的名称需要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个u4的长度属性去说明属性值所占用的位数即可。一个符合规则的属性表应该满足下面表格所定义的结构。
类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u1 info attribute_length
1、Code属性
Java程序方法体重的代码经过Javac编译器处理后,最终变为字节码指令存储在Code属性内。
2、Exception属性
这里的Exceptions属性是在方法表与Code属性平级的一项属性,Exceptions属性的作用是列举出方法中可能抛出的 受检查异常,也就是方法描述时在throws关键字后面列举的异常。
3、LineNumberTable
LineNumberTable属性用于描述Java源码行号与字节码行号之间的对应关系。它不是运行时必须的属性,但默认会生成到Class文件中,可以在Javac中分别使用–g:none或-g:lines选项来取消或要求生成这项信息。如果选择不生成LineNumberTable属性,对程序运行产生的最主要赢就是当抛出异常时,堆栈中将不会显示出错误的行号,并且在调试程序的时候,也无法按照源码行来设置断点。
4、LocalVariableTable属性
LocalVariableTable属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系,它也不是运行时必须的属性,但默认会生成到Class文件中,可以在Javac中分别使用-g:none或者-g:vars选项来取消或要求生成这项信息。如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所有的参数名称都将丢失,IDE将会使用诸如arg0、arg1之类的占位符代替原有的参数名,这对程序运行没有影响,但是对代码编写带来较大的不便,而且在调试期间无法根据参数名从上下文中获取参数值。
5、SourceFile属性
SourceFile属性用于记录生成这个Class文件的源码文件名称。这个属性也是可选的,可以分别使用Javac的-g:none或-g:source选项来关闭或要求生成这项信息。在Java中对于大多数的类来说,类名和文件名是一致的,但有一些特殊情况,例如内部类例外。
6、ConstantValue属性
ConstantValue属性的作用是通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量才可以使用这项属性。类似“int x=123”和“static int x=123”这样的变量定义在Java程序中是非常常见的事情,但虚拟机对这两种变量赋值的方式和时刻都有所不同。对于非static类型的变量的赋值是在实例构造器方法中进行的;而对于类变量,则有两种方式可以选择:在类构造器方法中或者使用ConstantValue属性。目前Sun Javac编译器的选择是:如果同时使用final和static来修饰一个变量,并且这个变量的数据类型是基本类型或者String的话,就生成ConstantValue属性来进行初始化,如果这个变量没有被final修饰,或者并非基本类型及字符串,则将会选择在方法中进行初始化。
7、InnerClasses属性
InnerClasses属性用于记录内部类与宿主类之间的关联。如果一个类中定义了内部类,那编译器将会为它机器它所包含的内部类生成InnerClasses属性。
8、BootstrapMethods属性
BootstrapMethods属性在JDK1.7发布后增加到了Class文件规范中,它是一个复杂的变长属性,位于类文件的属性表中。这个属性用于保存invokedynamic指令引用的引导方法限定符。《Java虚拟机规范》规定,如果某个类文件结构的常量池中曾经出现过CONSTANT_InvokeDynamic_info类型的常量,那么这个类文件的属性表中必须存在一个明确的BootstrapMethods属性,另外,即使CONSTANT_InvokeDynamic_info类型的常量在常量池中出现过多次,类文件的属性表中最多也只能有一个BootstrapMethods属性。

4个字节魔数->4个字节版本号->连续出现的常量表->类访问标志->类索引->父类索引->接口索引->字段表集合->方法表集合->属性表集合

7虚拟机类加载机制

我们了解了Java虚拟机的内存模型、垃圾回收机制、Class文件结构,本章我们将了解一下Java虚拟机的类加载机制。

概述
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
与那些在编译时需要进行连接工作的语言不同,在Java语言中,类的加载、连接和初始化过程都是在程序运行期完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。例如:如果编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类;用户可以通过Java预定义的和自定义类加载器,让一个本地的应用程序可以在运行时从网络或者其他地方加载一个二进制流作为程序代码的一部分,这种组装应用程序的方式目前已广泛应用于Java程序中。

类加载的时机
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中验证、准备、解析3个部分统称为连接。
在这里插入图片描述
上图中,加载、验证、准备、初始化、卸载这个5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的开始,而解析阶段则不一定,它在某种情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或者晚绑定)、注意,这里所说的按部就班的开始,而不是按部就班的进行或者完成,强调这点是因为这些阶段统称是互相交叉而且混合式进行的,通常会在一个阶段指向的过程中调用、激活另外一个阶段。

那什么情况下需要开始类加载过程的第一个阶段,加载?Java虚拟机规范中没有进行强制约束,这一点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

  1. 遇到new、getstatic、putstatic或者invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个累的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果累没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要制定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK1.7的动态语言支持的时候,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

对于这5种触发类进行初始化的场景,虚拟机规范找那个使用了一个很强的限定语:“有且只有”,这5种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。

类加载过程
加载
“加载”是“类加载”过程的一个阶段,在加载阶段,虚拟机需要完成以下3件事情:
1通过一个类的全限定名来获取定义此类的二进制字节流
2将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
3在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
虚拟机规范的这3点要求其实并不算具体,因此虚拟机实现与具体应用的 灵活度都是相当大的。例如:通过一个类的全限定名来获取定义此类的二进制字节流这一条, 它没有指二进制字节流要从一个Class文件中获取,准确的说是根本没有指明要从哪里获取,怎样获取。虚拟机设计团队在加载阶段搭建了一个非常开放的、广阔的“舞台”,Java发展的历程中,充满创造力的开发人员则在这个舞台上玩出了各种花样,许多举足轻重的Java技术都建立在这一基础上,例如:
1在ZIP包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础
2从网络中获取,这种常见最典型的应用就是Applet
3运行时计算生成,这种场景使用得最多的就是动态代理技术,在java.lang.reflect Proxy中,就是用了ProxyGenerator.generateProxyClass来为特定接口生成形式“*$Proxy”的代理类的二进制字节流
4由其他文件生成,典型的场景是JSP应用,即由JSP文件生成对应的Class类
5从数据库中读取,这种场景相对少见一些,例如有些中间件服务器可以选择把程序安装到数据库中来完成程序代码在集群中的分发
相对于类加载过程的其他阶段,一个非数字组的加载阶段(准确的说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的,因为加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式(即重写一个类加载器的loadClass()方法)。
对于数组类而言,情况就有所不同了,数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。但是数组类与类加载器仍然有很密切的关系,因为数组类的元素类型最终是要靠类加载器去创建,一个数组类创建过程就遵循以下规则:
1如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组C将在加载该组件类型的类加载器的类名称空间上表示
2如果数组的组件类型不是引用类型(如int[]数组),Java虚拟机将会把数据C标记为与引导类加载器关联
3数组类的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认为public
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式有虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。
加载阶段与连接阶段的部分内容是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害到虚拟机的自身安全。
Java语言本身是相对安全的语言,使用纯粹的Java代码无法做到诸如访问数组边界以外的数据、讲一个对象转型为它并未实现的类型、跳转到不存在的代码行之类的事情,如果这样做了,编译器将拒绝编译。但前面已经说过,Class文件并不以要求使用Java源码编译而来,可以使用任何途径产生,甚至包括使用十六进制编辑器直接编写来产生Class文件。在字节码语言层面上,上述Java代码无法做到的事情都是可以实现的,至少语义上是可以表达出来的。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。
验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载子系统中又占了相当大的一部分。Java虚拟机规范对这个阶段的限制、指导还是比较笼统的,规范列举了一些Class文件格式中的静态和结构化约束,如果验证到输入的字节流不符合Class文件格式的约束,虚拟机就应该抛出一个java.lang.VerifyError异常或者其子类异常,但具体应当检查哪些方面,如何检查,何时检查,都没有足够具体的要求和明确的说明。直到发布Java虚拟机规范第七版,增加了描述验证过程的篇幅,从整体上看,验证阶段大致会完成下面4个阶段的校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。
1、文件格式验证
这一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点:
1是否以魔数0xCAFEBABE开头
2主、次版本号是否在当前虚拟机处理范围之内
3常量池的长两种是否有不被支持的常量类型(检查常量tag标志)
4指向常量的各种索引值中是否有指向不存在的常量或者不符合类型的常量
5CONSTANT_Utf8_info型的长两种是否有不符合UTF8编码的数据
6Class文件中各个部分及文件本身是否有被删除的或附加的其他信息
实际上,第一阶段的验证点还远不止这些,上面这些只是从HotSpot虚拟机源码中摘抄的一小部分内容,该验证阶段的主要目的是保护输入的字节流能正确地解析病存储于方法区之内,格式上符合描述一个Java类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面的3个验证阶段全部是就方法区的存储结构进行的,不会再直接操作字节流。
2、元数据验证
第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,这个阶段可能包括的验证点如下:
这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)
这个类的父类是否继承了不允许被继承的类(被final修饰的类)
如果这个类不是抽象类,是否实现了其父类或者接口之中要求实现的所有方法
类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)
第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息。
3、字节码验证
第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型做完校验后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如:
保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个int类型的数据,使用时却按照long类型来家载入本地变量表中
保证跳转指令不会跳转到方法体以外的字节码指令上
保证方法体重的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系,完全不相干的一个数据类型,则是危险和不合法的
4、符号引用验证
最后一个阶段的校验发生在虚拟机将符号引用转换为直接引用的时候,这个转化动作将在连接的第三个阶段——解析阶段发生。符号一弄验证可以看做是对类自身以外(常量池的各种符号引用)的信息进行匹配性校验,统称需要校验下列内容:
符号引用中通过字符串描述的全限定名是否能找到对应的类
在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可以被当前类访问
符号引用验证的目的是确保解析动作能正常执行,如果无法通过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如java.lang.IlleglAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
对于虚拟机的类加载机制来说,验证阶段是一个非常重要的、但不是一定必要的阶段。如果所运行的全部代码都已经被反复使用和验证过,那么在是现阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备
准备阶段是正是为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的近包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:
public static int value = 123;
那变量value在准备阶段过后的初始值为0而不是123,因为这个时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。下表列出了Java中所有基本数据类型的零值。
数据类型 零值
int 0
long 0L
short (short) 0
char ‘\u0000’
byte (byte) 0
boolean false
float 0.0f
double 0.0d
reference null
上面提到,在“通常情况”下初始值是零值,那相对的会有一些“特殊情况”:如果累字段的字段数据表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,假设上面类变量value的定义变为:
public static final int value = 123;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在前一章讲解Class文件格式的时候已经出现过了多次,在Class文件中它以CONSTANT_Class_info、CONSTAN)Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,那解析阶段中所说的直接引用与符号引用又有什么关联呢?
符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是他们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经存在在内存中。
虚拟机规范中并未规定解析阶段发生的具体时间,只要求了在执行anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield和putstatic这16个用于操作符号引用的字节码指令之前,先对他们所使用的符号引用进行解析。所以虚拟机实现可以根据需要来判断到底是在类加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。
对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)从而避免解析动作重复进行。无论是否真正执行了多次解析动作,虚拟机需要保证的是在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直成功;同样的,如果第一次解析失败了,那么其他指令对这个符号的解析请求也应该收到相同的异常。
对于invokedynamic指令,上面的规则则不成立。当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对于其他invokedynamic指令也同样生效。因为invokedynamic指令的目的本来就是用于动态语言支持,它所对应的引用称为“动态调用点限定符”,这里的“动态”的含义就是必须等到程序实际运行到这条指令的时候,解析动作才能进行。相对的,其余可出发解析的指令都是“静态”的,可以在刚刚完成加载阶段,还没开始执行代码时就进行解析。
解析动作主要针对类或者接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,分别对应于常量池的CONSTAN_Class_info、CONSTAN_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info7种常量类型。下面将讲解前面4种的解析过程。
1、类或接口的解析
假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或者接口C的直接引用,
public class D {
public static N n = new C();
}
那虚拟机完成整个解析的过程需要下面3个步骤:
1)如果C不是一个数组类型,那么虚拟机将会把代表N的全限定名传递给D的类加载器去家在这个类C。在加载过程中没有雨元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如家在这类的父类或者实现的接口。一旦这个加载过程出现了任何异常,解析过程就宣告失败。
public class D {
public static Integer [] n = new Integer[3];
}
2)如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是一个类似“[Ljava/lang/Integer”的形式,那将会按照第1点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表此数组维度和元素的数据对象。
3)如果上面的步骤没有出现任何的异常,那么C在虚拟机中实际上已经成为一个有效的类或者接口了,但在解析完成之前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。
2、字段解析
要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或者接口的符号引用。如果在解析这个类或者接口符号引用的过程中出现了任何的异常,都会导致字段符号引用解析的失败。
如果解析成功完成,那将这个字段所属的类或者接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索。

  1. 如果C本身就包含了简单名称和字段名描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  2. 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  3. 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  4. 否则,查找失败,抛出java.lang.NoSuchFieldError异常。
    如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备字段的访问权限,将抛出java.lang.IllegalAccessError异常。

在实际应用中,虚拟机的编译器实现可能会比上述规范要求更加的严格一些,如果有一个同名字段同时出现在C的接口和父类中,或者同时在自己或父类的多个接口中出现,那编译器将可能拒绝编译。下面的代码示例中,如果注释了Sub类中的“public static int A = 4;”,接口与父类同时存在字段A,那编译器将提示”The field Sub.A is ambiguous”,并且拒绝编译这段代码。
public class TestClass {

interface Interface0 {
    int A = 0;
}

interface Interface1 extends Interface0 {
    int A = 1;
}

interface Interface2 {
    int A = 2;
}

static class Parent implements Interface1 {
    public static int A = 3;
}

static class Sub extends Parent implements Interface2 {
    public static int A = 4;
}

public static void main(String[] args) {
    System.out.println(Sub.A);
}

}
3、类方法解析
类方法解析的第一个步骤与字段解析一样,也需要先解析出类方法表的class_index项中索引的方法所属的类或者接口的符号引用,如果解析成功,我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的类方法搜索。
类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,那就直接抛出ava.lang.IncompatibleClassChangeError异常。
如果通过了第一步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
否则,在类C实现的接口列表及它们的父类接口中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时查找结束,抛出java.lang.AbstractMethodError异常。
否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError。
最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError异常。
4、接口方法解析
接口方法也需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索。

  1. 与类方法解析不同,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
  2. 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  3. 否则,在接口C的父接口中递归查找,直到java.lang.Object类为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
  4. 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常。
    由于接口中的所有方法默认都是public的,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出java.lang.IllegalAccessError异常。

初始化
类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义加载类加载器参与外,其余动作完全由虚拟机主导和控制。到了初始化阶段,财政在开始执行类中定义的Java程序代码。
在准备阶段,变量已经赋过一次系统要求的初始化值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度去表达:初始化阶段是执行类构造器clinit()方法执行过程中一些可能会影响程序运行行为的特点和细节,这部分相对更贴近于普通的程序开发人员。
clinit()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,当时不能访问。
public class Test {
static {
i = 0; //给变量赋值可以正常编译通过
System.out.print(i); //这句编译器会提示“非法向前引用”
}
static int i = 1;
}
clinit()方法与类的构造器不同,它不需要显示地调用父类构造器,虚拟机会保证在子类的clinit()方法执行之前,父类的clinit()方法已经执行完毕。因此在虚拟机中第一个被执行的clinit()方法的类肯定是java.lang.Object。
由于父类的clinit()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作,如下代码中,字段B的值将会是2而不是1。
static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}

public static void main(String[] args) {
System.out.println(Sub.B);
}
clinit()方法对于类或者接口来说并不是必需的,如果一类中没有静态语句块,也没有对变量的赋值操作,那么编译可以不为这个类生成clinit()方法。
接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成clinit()方法。但是接口与类不同的是,执行接口的clinit()方法不需要先执行父接口的clinit()方法。只有当肤疾克中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始时也一样不会执行接口的clinit()方法。
虚拟机会保证一个类的clinit()方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的clinit()方法,其他线程都需要阻塞等待,知道活动线程执行clinit()方法完毕。如果在一个类的clinit()方法中有耗时很长的操作,就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
类加载器
虚拟机设计团队把类加载阶段中的“通过一个雷的全限定名来获取描述此类的二进制字节流”这个动作放到Java机外部来实现,以便让应用程序自己决定如何获取所需的类。实现这个动作的代码模块称为“类加载器”。
类加载器可以说是Java语言的一项创新,也是Java语言流行的重要原因之一,它最初是为了满足Java Applet的需求而开发出来的。虽然目前Java Applet技术基本上已经“死掉”,但类加载器却在类层次划分、OSGi、热部署、代码加密等领域大放光彩,成为了Java技术体系中的一块重要的基石,可谓是失之桑榆,收之东偶。

类与类加载器
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段,对于任意一个类,都需要由加载它的类加载器和这个累本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达的更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器的前提下才有意义,否则,基石这两个类来源自同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里所指的“相等”,包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。如果没有注意到类加载器的影响,在某些情况下可能会产生具有迷惑性的结果,下面代码演示了不同的类加载器对instanceof关键字运算的结果的影响。
package com.xuangy.classloader;

import java.io.IOException;
import java.io.InputStream;

public class ClassLoaderTest {
public static void main(String[] args) throws Exception {

    ClassLoader myClassLoader = new ClassLoader() {
        @Override
        public Class<?> loadClass(String name) throws ClassNotFoundException {
            try {
                String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                InputStream is = getClass().getResourceAsStream(fileName);
                if (is == null) {
                    return super.loadClass(name);
                }
                byte[] b = new byte[is.available()];
                is.read(b);
                return defineClass(name, b, 0, b.length);
            } catch (IOException e) {
                throw new ClassNotFoundException(name);
            }
        }
    };

    Object object = myClassLoader.loadClass("com.xuangy.classloader.ClassLoaderTest").newInstance();
    System.out.println(object.getClass());
    System.out.println(object instanceof com.xuangy.classloader.ClassLoaderTest);
}

}
运行结果:
class com.xuangy.classloader.ClassLoaderTest
false
上面的代码中构造了一个简单的类加载器,尽管很简单,但是对于这个演示来说还是够用了。它可以加载与自己在同一路径一下的Class文件。我们使用这个类加载器去加载了一个名为“com.xuangy.classloader.ClassLoaderTest”的类,并实例化了这个类的对象。两行输出结果中,从第一句可以看出,这个对象确实是com.xuangy.classloader.ClassLoaderTest实例化出来的对象,但从第二句可以发现,这个对象与类com.xuangy.classloader.ClassLoaderTest做所属类型检查的时候却返回了false,这是因为虚拟机中存在了两个ClassLoaderTest类,一个是由系统应用程序类加载器加载的,另外一个是由我们自定义的类加载器加载的,虽然都来自与同一个Class文件,但仍然是两个独立的类,做对象所属类型检查时结果自然是false。

双亲委派模型
从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全部继承自抽象类java.lang.ClassLoader。
从Java开发人员的角度来看,类加载器可以划分的更细致一些,绝大部分Java程序都会使用到以下3种系统提供的类加载器。

  • 启动类加载器(Bootstrap ClassLoader):前面已经介绍过,这个类将存放在\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名称不符合类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即。

  • 扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载器\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

  • 应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher AppClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中的默认类加载器。
    我们的应用程序都是由这3种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。这些类加载器之间的关系一般如下图。
    在这里插入图片描述
    上图中展示的类加载器之间的这种层次关系,称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。
    类加载器的双亲委派模型在JDK1.2期间被引入并被广泛应用于之后几乎所有的Java程序中,但它并不是一个强制性的约束模型,而是Java设计者推荐给开发者的一种类加载实现方式。
    双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
    使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统将会出现多个不同的Object类,Java类型体系中最基本的行为也就无法保证,应用程序也将会变得一片混乱。
    双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现确非常的简单,实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法中,逻辑清晰易懂:先检查是否已经被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
    protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
    {
    synchronized (getClassLoadingLock(name)) {
    // First, check if the class has already been loaded
    Class<?> c = findLoadedClass(name);
    if (c == null) {
    long t0 = System.nanoTime();
    try {
    if (parent != null) {
    c = parent.loadClass(name, false);
    } else {
    c = findBootstrapClassOrNull(name);
    }
    } catch (ClassNotFoundException e) {
    // ClassNotFoundException thrown if class not found
    // from the non-null parent class loader
    }

              if (c == null) {
                  // If still not found, then invoke findClass in order
                  // to find the class.
                  long t1 = System.nanoTime();
                  c = findClass(name);
    
                  // this is the defining class loader; record the stats
                  sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                  sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                  sun.misc.PerfCounter.getFindClasses().increment();
              }
          }
          if (resolve) {
              resolveClass(c);
          }
          return c;
      }
    

    }

破坏双亲委派模型
上文提到过双亲委派模型并不是一个强制性的约束模型,而是Java设计者推荐给开发的类加载器实现方式。在Java的世界中大部分的类加载器都遵循这个模型,但也有例外,到目前为止,双亲委派模型主要出现过3次大规模的“被破坏”情况。
双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即JDK1.2发布之前,由于双亲委派模型在JDK1.2之后才被引入,而类加载器和抽象类java.lang.ClassLoader则在JDK1.0时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK1.2之后的java.lang.ClassLoader添加了一个新的protected方法findClass(),在此之前,用户去继承java.lang.ClassLoader的唯一目的就是为了重写loadClass()方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的loadClass()。
上一节我们已经看过loadClass()方法的代码,双亲委派的具体逻辑就实现在这个方法之中,JDK1.2之后已不提倡用户去覆盖loadClass()方法,而应当把自己的类加载逻辑写到findClass()方法中,在loadClass()方法的逻辑里如果父类加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派规则的。
双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,双亲委派很好地解决了各个类加载器的基础类的统一问题(越基础的类越由上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API,但世事往往没有绝对的完美,如果基础类又要调用回用户的代码,那该怎么办?
这并非不可能的事情,一个典型的例子就是JNDI服务,JNDI现在已经是Java标准服务,它的代码由启动类加载器去加载,但JNDI的目的就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI,Server Provider Interface)的代码,但启动类加载器不可能“认识”这些代码啊!那该怎么办?
为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上线文类加载器。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
有了线程上线文类加载器,就可以做一些“舞弊”的事情了,JNDI服务使用这个线程上线文类加载器所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型的层次结构类逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也就是无可能奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。
双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的,这里所说的“动态性”指的是当前一些非常“热门”的名词:代码热替换、模块热部署等,说白了就是希望应用程序能像我们的计算机外设那样,接上鼠标、U盘,不用重启机器就能立即使用,鼠标有问题或要升级就换一个鼠标,不用停机也不用重启。对于个人计算机来说,重启一次其实没有什么大不了的,但对于一些生产系统来说,关机重启一次可能就要被列入生产事故,这种情况下热部署就对软件开发者,尤其是企业级软件开发者具有很大的吸引力。
Sun公司所提出的JSR-294、JSR-277规范在于JCP组织的模块化规范之争中落败给JSR-291,虽然Sun不甘失去Java模块化的主导权,独立发展Jigsaw项目,但目前OSGi已经成为业界“事实上”的Java模块化标准,而OSGi实现模块化热部署的关键则是它自定义的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一期换掉以实现代码的热替换。
在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构,当收到类加载器请求时,OSGi将按照下面的顺序进行类搜索:
将以java.*开头的类委派给父类加载器加载。
否则,将委派列表名单内的类委派给父类加载器加载。
否则,将Import列表中的类委派给Export这个类的Bundle的类加载器加载。
否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。
否则,查找Dynamic Import列表的Bundle,委派给对应的Bundle的类加载器加载。
否则,查找失败。
上面的查找顺序中只有开头两点仍然符合双亲委派规则,其余的类查找都是在平级的类加载器中进行的。

本章介绍了类加载过程的加载、验证、准备、解析、初始化介绍了类加载器的工作原理及其对虚拟机的意义。

6如何在class文件中定义类
7如何将类加载到虚拟机中
8虚拟机如何执行定义在class文件里的字节码

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值