第一章 JVM
JVM的运行机制
JVM是用于运行Java字节码的虚拟机,包括一套字节码指令集,一组程序寄存器,一个虚拟机栈,一个虚拟机堆,一个方法区和一个垃圾回收器。JVM运行在操作系统之上,不与硬件设备直接交互。
Java源文件经过编译器编译成字节码文件,字节码文件再被JVM的解释器编译成机器码在不同的操作系统上运行,不同操作系统的解释器是不同的,但是基于解释器实现的虚拟机是相同的,这就是Java能够跨平台的原因。
Java虚拟机包括一个类加载器子系统,运行时数据区,执行引擎,本地接口库。
- 类加载器子系统用于将字节码文件加载在JVM中;
- 运行时数据区用于保存JVM在运行过程中产生的数据,包括程序计数器,方法区,本地方法区,虚拟机栈,虚拟机堆;
- 执行引擎包括即时编译器和垃圾回收器,即时编译器用于将字节码文件编译成具体的机器指令,垃圾回收器用于回收JVM在运行中不再使用的对象;
- 本地接口库调用操作系统的本地方法完成相应的指令操作;
多线程
在多核操作系统中,JVM允许一个进程内同时并发执行多个线程。JVM的线程与操作系统的线程是相互对应的。当JVM线程的本地存储,缓冲区分配,同步对象,栈,程序计数器等准备工作完成后,JVM会调用操作系统的接口创建一个原生线程。在原生线程初始化完毕后,就会调用Java线程的run()方法执行该线程,在线程结束时,会释放原生线程和Java线程所对应的资源。
在JVM后台运行的线程主要有以下几个:
- 虚拟机线程:虚拟机线程在JVM到达安全点时出现;
- 周期性任务线程:通过定时器调度线程来实现周期性操作的执行;
- GC线程:GC线程支持JVM中不同的垃圾回收活动;
- 编译器线程:编译器线程在运行时将字节码动态编译成本地平台机器码,是JVM跨平台的具体实现;
- 信号分发线程:接收发送到JVM的信号并调用JVM方法;
JVM的内存区域
JVM的内存区域分为线程私有区(程序计数器,栈,本地方法区),线程共享区(堆,方法区)和直接内存。
线程私有区域的生命周期和线程相同,随线程的启动而创建,随线程的结束而销毁。在JVM内,每个线程都与操作系统的原生进程直接映射,因此这部分内存区域的存在与否和本地线程的启动和销毁对应。
线程共享区域随虚拟机的启动而创建,随虚拟机的关闭而销毁。
直接内存也叫作堆外内存,它并不是JVM运行时数据区的一部分,但是在并发编程中被频繁使用。Java进程可以通过堆外内存技术避免在Java堆和Native堆中来回复制数据带来的资源占用和性能消耗,因此堆外内存在高并发应用场景下被广泛使用(HBase,Hadoop)
程序计数器:线程私有,无内存溢出问题
程序计数器是一块很小的内存区域,用于存储当前运行的线程所执行的字节码的行号指示器。每一个运行的线程都有一个独立的程序计数器,当方法正在执行时,该方法的程序计数器记录的是实时虚拟机字节码指令的地址,如果该方法是Native方法,则该值为空。
程序计数器属于“线程私有”的内存区域,它是唯一没有内存溢出的区域。
虚拟机栈:线程私有,描述Java方法的执行过程
虚拟机栈是描述Java方法执行过程的内存模型,它在当前栈帧中记录了局部变量表,操作数栈,动态链接,方法出口等信息。同时,栈帧还保存了部分运行时数据及其数据结构,处理动态链接方法的返回值和异常分配。
栈帧用来记录方法的执行过程,在方法被执行时,虚拟机会为之创建一个与之对应的栈帧,方法的执行和结束对应栈帧在虚拟机栈中的入栈和出栈。无论方法是正常运行完成还是异常完成,都视为方法运行结束。每一个运行的线程当前只有一个栈帧处于活动状态。
本地方法区:线程私有
本地方法区和虚拟机栈的作用类似,区别是虚拟机栈为执行Java方法服务,本地方法栈为Native方法服务。
堆:运行时数据区,线程共享
在JVM运行过程中创建的对象和产生的数据被保存在堆中,堆是线程共享的内存区域,也是垃圾收集器进行垃圾回收最主要的内存区域。由于现代JVM采用分代收集算法,因此Java堆还可以从GC的角度分为新生代,老生代和永久代。
方法区:线程共享
方法区也被称为永久代,用于存储常量,静态变量,类信息,即时编译器编译后的机器码,运行时常量池等数据。
JVM将GC分代收集扩展至方法区,即使用Java堆的永生代来实现方法区,这样JVM的垃圾收集器就可以像管理Java堆一样管理这部分内存。永久代的内存回收主要针对常量池的回收和类的卸载,可回收的对象很少。
在类信息中不但保存了类的版本,字段,方法,接口等描述信息,还保存了常量信息。
在即时编译后,代码的内容将在执行阶段(类加载结束后)被保存在方法区的运行时常量池中。
JVM的运行时内存
JVM运行时内存也叫做JVM堆,从GC的角度可以分为新生代,老生代和永久代。其中新生代默认占1/3堆空间,老生代默认占2/3空间,永久待占非常少的堆空间。新生代又分为Eden区,ServivorFrom区和ServivorTo区,Eden区默认占8/10新生代空间,ServivorFrom区和ServivorTo区分别占1/10新生代区。
新生代:Eden区,ServivorFrom区和ServivorTo区
JVM新创建的对象(除了大对象外)会被存放在新生代,由于JVM会频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。
- Eden区:Java新创建的对象首先会存放在Eden区,如果新创建的对象属于大对象,则直接将其分配到老年代。大对象的定义与具体的JVM版本,堆大小和垃圾回收策略有关,一般为2KB~128KB,可通过xx:PretenureSizeThreshold设置大小。在Eden区的内存空间不足时会触发MinorGC,对新生代进行一次垃圾回收
- ServivorTo区:保留上一次MinorGC时的幸存者
- ServivorFrom区:将上一次MinorGC时的幸存者作为这次MinorGC的被扫描者
新生代的GC过程叫做MinorGC,采用复制算法实现,具体过程如下:
(1) 将在Eden区和ServivorFrom区中存活的对象复制到ServivorTo区中。如果某对象达到老生代的标准,则将其复制到老年代,同时将该对象的年龄加一;如果ServivorTo的内存不足,则直接复制到老年代;如果该对象为大对象也直接复制到老年代;
(2)清空Eden区和ServivorFrom区的对象;
(3)将ServivorTo区和ServivorFrom区互换,原来的ServivorTo区称为下一次GC的ServivorFrom区。
老年代
老年代主要存放生命周期较长的对象和大对象。老年代的GC过程叫做MajorGC。在老年代中,对象比较稳定,不会经常触发MajorGC。在进行MajorGC之前,JVM会进行一次MinorGC,在MinorGC完成后,仍然出现老年代内存空间不足或者无法找到足够大的连续空间分配给新创建的大对象时,才会触发MajorGC。
Major采用标记清除算法,该算法首先会扫描所有的对象并标记存活的对象,然后回收所有未被标记的对象,并释放内存空间。由于需要扫描所有的对象,所以MajorGC的耗时较长。而且该算法容易产生内存碎片。
永久代
永久代指内存的永久保存区域,主要存放Class和Meta(元数据)的信息。GC不会在运行过程中对永久代的内存进行清理,这也导致了永久代的内存会随着加载的Class文件的增加而增加,在加载过多后会抛出内存溢出异常。
需要注意的是,在Java8中已经使用元数据区(元空间)代替永久区。区别:元数据区直接使用操作系统的内存,元空间的大小不受JVM内存的限制,只和操作系统的内存有关。
垃圾回收与算法
如何确定垃圾
Java采用引用计数法和可达性分析来确定对象是否应该被回收。
引用计数法
在为对象添加一个引用时,引用计数器加1;在为对象删除一个引用时,引用计数器减一,当该对象的引用计数器的值为0,说明该对象没有被引用,可以被回收。
引用计数法容易出现循环引用的问题,即两个对象相互引用,导致这两个对象一直无法回收。
可达性分析
为了解决引用计数法的循环引用问题,Java还采用了可达性分析来判断对象是否可以回收。具体做法是定义一些GC Roots对象,然后以这些对象为起点向下搜索,如果在GC Roots和一个对象没有可达路径,则称该对象是不可达的。不可达对象要经过至少两次标记才能判定是否可以被回收。
Java中常用的垃圾回收算法
Java中常用的垃圾回收算法有标记清除,复制,标记整理和分代收集这四种。
标记清除算法
标记清除算法是基础的垃圾回收算法,其过程分为标记和清除两个阶段。在标记阶段标记需要回收的对象,在清除阶段清除可回收的对象并释放对应的内存空间。
由于该算法在释放对象占用的空间后没有对内存空间进行重新整理,因此如果内存中的小对象居多,会引起内存碎片化的问题,从而导致大对象无法获得足够的连续的空间。
复制算法
复制算法是为了解决标记清除算法引发的内存碎片化设计的。它将内存分成大小相同的两块区域,即区域1和区域2,新生成的对象都放在区域1中,当区域1中的对象存储满了之后,会对区域1的对象进行一次标记,将区域1中被标记后仍然存活的对象复制到区域2中,此时区域1已经不存在任何存活的对象,直接清除整个区域1的内存即可。
复制算法的内存清理效率较高且易于实现,但由于同一时刻只有一个内存区域可以使用,即可用的内存区域被压缩到原来的一般,存在大量的内存浪费。同时,当系统存在大量的长时间存活的对象,这些对象在区域1和区域2的来回复制也会导致运行效率降低。
标记整理算法
标记整理算法结合了标记清除算法和复制算法的优点,其标记阶段和标记清理算法的阶段相同,在标记完成之后将存活的对象移动到内存的另一端,将这一端的对象清除并释放内存。
分代收集算法
分代收集算法是根据对象的不同类型将内存划分成不同的区域,JVM将堆划分成新生代和老年代。新生代主要存放新生成的对象,其特点是对象数量多但是生命周期短,每次进行垃圾回收都有大量的对象被回收;老年代主要存放大对象和生命周期长的对象,可回收的对象相对较少。JVM根据不同的区域对象的特点选择了不同的算法。
目前,大部分JVM在新生代都采用了复制算法,因为在每次进行垃圾回收时都有大量的垃圾被回收,需要复制的对象较少,不存在大量对象在内存中来回复制的问题。
JVM将新生代进一步划分为一块较大的Eden区和两块较小的Servivor区(ServivorFrom和ServivorTo)
老年区主要存放生命周期较长的对象和大对象,每次只有少量非存活的对象被回收,因此在老年代采用标记清除算法。
在JVM中还有一个区域,即方法的永久代,用来存储Class类,常量,方法描述等。在永久代主要回收废弃的常量和无用的类。
JVM内存中的对象主要被分配到Eden区和ServivorFrom区,在少数情况下会直接分配到老年区。在新生代的Eden区和ServivorFrom区的内存空间不足时会触发一次MinorGC。在MinorGC后,Eden区和ServivorFrom区中存活的对象被复制到ServivorTo区,然后清除Eden区和ServivorFrom区。如果此时在ServivorTo区找不到足够的连续的空间分配给某个对象,则将这个对象复制到老年代。若Servivor区的对象经过一次GC仍然存活,则年龄加一,默认情况下,年龄达到15时,会被移到老年代。
Java中的4种引用类型
在Java中一切即对象,对象的操作是通过该对象的引用来实现的。Java中的引用类型有4种,分别为强引用,软引用,弱引用和虚引用。
- 强引用:在Java中最常用的就是强引用,在把一个对象赋给一个引用变量时,这个引用对象就是强引用。有强引用的对象一定为可达状态,不会被垃圾回收机制回收。因此强引用是造成Java内存泄漏的主要原因。
- 软引用:通过SoftReference类实现,如果一个对象只有软引用,则在系统内存空间不足时该对象将被回收。
- 弱引用:通过WeakReference类实现,如果一个对象只用弱引用,则在垃圾回收过程中一定会被回收。
- 虚引用:虚引用通过PhantomReference类实现,虚引用和引用队列联合使用,主要用于跟踪对象的垃圾回收状态。
分区收集算法
分区算法将整个堆空间划分为连续的大小不同的小区域,对每个小区域都单独进行内存使用和垃圾回收,这样做的好处是可以根据每个小区域内存的大小灵活使用和释放内存。如果垃圾回收机制一次回收整个堆内存,需要的长时间停顿将影响系统运行的稳定性。
垃圾收集器
JVM针对新生代和老年代分别提供了不同的垃圾收集器,针对新生代提供的垃圾收集器有Serial,ParNew,Parallel Scavenge;针对老年代提供的垃圾收集器有Serial Old,Parallel Old,CMS,还有针对不同区域的G1分区收集算法。
Serial垃圾收集器:单线程,复制算法
Serial垃圾收集器是基于复制算法实现的。它是一个单线程收集器,在它正在进行垃圾收集时,必须暂停其他工作直到垃圾收集结束。
在单CPU的运行环境中,没有线程交互开销,可以获得最高的单线程垃圾收集效率,因此,Serial垃圾收集器是Java虚拟机运行在Client模式下的新生代的默认垃圾收集器。
ParNew 垃圾收集器:多线程,复制算法
ParNew垃圾收集器是Serial垃圾收集器的多线程实现,同时使用了复制算法,它采用了多线程工作,除此之外和Serial垃圾收集器几乎一样。ParNew垃圾收集器在进行垃圾收集过程中会短暂停止其他工作线程的工作,它是Java虚拟机运行在Server模式下的新生代的默认垃圾收集器。
ParNew垃圾收集器默认开启与CPU同等数量的线程进行垃圾回收。
Parallel Scavenge垃圾收集器:多线程,复制算法
Parallel Scavenge收集器是为提高新生代垃圾收集效率而设计的垃圾收集器,基于多线程复制算法实现,在系统吞吐量上有很大优化。
Parallel Scavenge通过自适应调节策略提高系统吞吐量,提供了三个参数用于调节,控制垃圾收集的停顿时间和吞吐量,控制自适应调节策略是否开启与否。
Serial Old 垃圾收集器:单线程,标记整理算法
Serial Old垃圾收集器是Serial垃圾收集器的老年代实现,和Serial同样使用单线程执行,不同的是,Serial Old针对老年代的生命周期的特点采用标记整理算法。Serial Old垃圾收集器是Java虚拟机运行在Client模式下的老年代的默认垃圾收集器。
Parallel Old垃圾收集器:多线程,标记整理算法
在设计上优先考虑系统吞吐量,其次考虑停顿时间等因素
CMS垃圾收集器
主要目的是达到最短的垃圾回收停顿时间。
工作机制:
- 初始标记:只标记和GC Roots直接关联的对象,时间很快,需要暂停所有的工作线程;
- 并发标记:和用户线程一起工作,执行GC Roots跟踪标记过程,不需要停止工作线程;
- 重新标记:在并发标记过程中用户线程继续工作,导致在这个过程中部分的对象的状态可能会发生变化,为了确保这些对象状态的正确性,需要重新进行标记。需要暂停工作进程;
- 并发清除:与用户线程一起工作,执行GC Roots不可达对象的任务,不需要暂停工作进程。
G1垃圾收集器
G1垃圾收集器为了避免全区域地进行垃圾回收引起的系统停顿,将堆内存划分成固定大小的几个区域。G1垃圾收集器通过内存区域独立划分使用和根据不同优先级进行垃圾回收的机制,确保了G1垃圾收集器在有限的时间内获得最高的垃圾回收效率。
相对于CMS,G1有两个突出的改进:
- 使用标记整理算法,不产生内存碎片;
- 可以精准地控制系统停顿时间,在不牺牲吞吐量的前提下实现短停顿垃圾回收。
Java网络编程模型
阻塞I/O模型
阻塞I/O模型是常见的I/O模型,在读写数据时客户端会发生阻塞。工作流程为:在用户线程发起I/O请求之后,内核会检查数据是否准备就绪;在数据准备的过程中,用户线程一直阻塞等待到内存数据就绪。在内存数据就绪后,内核将数据复制到用户线程,并返回I/O执行结果给用户线程,此时用户线程将解除阻塞状态并开始处理数据。典型的阻塞I/O模型的例子为data = socket.read();如果内核数据没有准备就绪,Socket线程就会一直阻塞在read()中等待数据就绪。
非阻塞I/O模型
非阻塞I/O模型是指用户在发送一个I/O请求后无须阻塞就能马上得到内核返回的结果,如果返回的结果为false,表示内核数据还没有准备好;一旦内核数据准备好,并且再次收到用户的I/O请求,内核就会立刻将数据复制到用户进程并通知用户线程。在内存数据还没准备好时,用户线程可以处理其他任务。
多路复用I/O模型
多路复用I/O模型是多线程并发编程用得较多的模型。Java NIO就是基于多路复用I/O模型实现的。在多路复用I/O模型中有一个Selector的线程,会不断轮询多个Socket的状态。只有在Socket有读写事件时,才通知用户进行读写操作。
当事件响应体(消息体)很大时,Selector线程就会成为性能的瓶颈,导致后续的事件迟迟得不到处理。在实际应用中,Selector线程只做数据的接收和转发,不做复杂逻辑处理,将具体的业务操作转发给后面的业务线程处理。
信号驱动I/O模型
在信号驱动I/O模型中,用户在发起一个I/O请求时,系统会为该请求对应的Socket注册一个信号函数,然后用户可以继续执行其他的业务逻辑。当内核数据准备就绪后,系统会发送一个信号给用户线程,用户线程接收到该信号后,会在信号函数中完成对应的I/O读写操作。
异步I/O模型
在异步I/O模型中,用户线程会发起一个asynchronous read操作到内核,内核在接收到该操作后立即返回一个状态说明请求是否成功发起,该过程用户线程不会发生阻塞。接着,内核会等待数据准备完成后并将数据复制到用户线程中,在数据复制完成后内核会发送一个信号到用户线程,通知用户线程该操作已经完成。在异步I/O模型中,用户线程不需要关心整个I/O操作是怎样实现的,只需发送一个请求,在接收到内核返回的成功或失败的信息时说明I/O操作已经完成,可以直接使用数据了。
Java I/O
在整个Java.io包中最重要的是5个类和1个接口。5个类指的是File, OutputStream, InputStream, Writer, Reader,一个接口指的是Serializable。
Java NIO
Java NIO的实现主要涉及三大核心内容:Selector(选择器),Channel(通道),Buffer(缓冲区)。Selector用于监听多个Channel的事件,比如连接打开和数据到来。因此一个线程可以实现对多个channel的管理。传统I/O基于数据流实现I/O读写操作,Java NIO基于Channel和Buffer进行I/O读写操作,并且数据总是从Channel读取到Buffer中,从Buffer写入到Channel中。另外传统I/O的流操作是阻塞模式的,NIO是非阻塞的。
JVM的类加载机制
JVM的类加载阶段
JVM的类加载分为5个阶段:准加载,验证,准备,解析,初始化。在类初始化后就可以使用该类的信息,在一个类不再需要时就可以从JVM中卸载。
- 加载:指JVM读取Class文件,并根据Class文件描述创建java.lang.class对象的过程。类加载的过程主要包含将Class文件读取到运行时区域的方法区,在堆创建java.lang.class对象,在并封装类在方法区的数据结构的过程。在读取Class文件既可以通过文件的形式读取,也可以通过jar包,war包,还可以通过代理自动生成Class或其他方法读取。
- 验证:主要用于确保Class文件符合当前虚拟机的要求,保障虚拟机的安全。
- 准备:主要工作是在方法区为类变量分配空间并设置初始值。这里需要注意final类型和非final类型值的变量在准备阶段的数据初始化过程不同。
public static long value = 1000;在准备阶段的初始值为0,将value设置为1000是在对象初始化时完成的,因为JVM在编译阶段会将静态变量的初始化操作定义在构造器中。如果将变量声明为final类型,则该过程是准备阶段完成的。 - 解析:JVM将常量池中的符号引用修改成直接引用。
- 初始化:主要通过执行类构造器的方法为类进行初始化,方法是在编译阶段由编译器自动收集类中的静态代码块和变量的赋值操作组成的。在一个类中既没有静态变量赋值操作也没有静态语句块时,编译器不会为该类生成方法
类加载器
- 启动类加载器:负责加载Java_HOME/lib目录下的类库,或通过-Xbootclasspath参数指定路径中被虚拟机认可的类库;
- 扩展类加载器:负责加载Java_HOME/lib/ext目录下的类库,或通过java.ext.dirs系统变量加载指定路径中的类库;
- 应用程序类加载器:负责加载用户路径上的类库。
双亲委派机制
双亲委派机制是指一个类在收到类加载请求后不会自己尝试加载这个类,而是将其委托为自己的父类,其父类在收到类加载请求时又会将其委托为自己的父类,以此类托,所有的类加载请求都要被向上委派到启动类加载器中。若父类加载器在接收到类加载请求后发现自己也无法加载该类,则父类将信息反馈给子类并向下委派子类加载器加载该类,直到该类加载成功,若找不到该类,则JVM抛出CLassNotFoud异常。
双亲委派机制的核心是保证类的唯一性和安全性。