Java面试核心知识点整理1——JVM

为了准备tw的面试赶紧恶补一下…

JVM

JVM的运行机制

JVM(Java Virtual Machine)是用于运行Java字节码的虚拟机,。JVM运行在操作系统之上,不与硬件设备直接交互。Java虚拟机包括一个类加载子系统、运行时数据区、执行引擎和本地接口库。本地接口库通过调用本地方法库与操作系统交互。
在这里插入图片描述

  • 类加载器子系统用于将编译好的.class文件加载到JVM中。
  • 运行时数据区用于存储在JVM运行过程中产生的数据,包括程序计数器、方法区、本地方法区、虚拟机栈和虚拟机堆。
  • 执行引擎包括即时编译器和垃圾回收器,即时编译器用于将Java的字节码编译成具体的机器码,垃圾回收器用于回收在运行过程中不再使用的对象。
  • 本地接口库用于调用操作系统的本地方法库完成具体的指令操作。

Java源文件在通过编译器之后被编译成相应的.class字节码文件,.class文件又被JVM中的解释器编译成机器码在不同的操作系统上运行。每种操作系统的解释器都是不同的,但基于解释器实现的虚拟机是相同的,这也是Java能够跨平台的原因。在一个Java进程开始运行后,虚拟机就开始实例化了,有多个进程启动就会实例化多个虚拟机实例。进程退出或关闭,则虚拟机实例消亡,在多个虚拟机实例之间不能共享数据。

Java程序的具体运行过程如下:
(1)Java源文件被编译器编译成字节码文件
(2)JVM将字节码文件编译成相应操作系统的字节码
(3)机器码调用相应操作系统的本地方法库执行相应的方法


多线程

在多操作系统上,JVM允许在一个进程内同时并发执行多个线程。JVM中的线程与操作系统中的线程是互相对应的,在JVM线程的本地存储、缓冲区分配、同步对象、栈、程序计数器等准备工作都完成时,JVM会调用操作系统的接口创建一个与之对应的原生线程。在JVM线程运行结束时,原生线程随之被收回。操作系统负责调度所有线程,并为其分配CPU时间片。在原生线程初始化完毕时,就会调用Java线程的run()方法执行该线程,在线程结束后,会释放原生线程和Java线程对应的资源。

在JVM后台运行的线程主要有:

  • 虚拟机线程:虚拟机线程在JVM到达安全点时出现
  • 周期性线程任务:通过定时器调度线程来实现周期性操作的执行
  • GC线程:GC线程支持JVM中不同的垃圾回收活动
  • 编译器线程:编译器线程在运行时将字节码动态编译成本地机器码,是JVM跨平台的具体实现
  • 信号分发线程:接收发送到JVM的信号并调用JVM方法

JVM的内存区域

JVM的内存区域分为线程私有区域(程序计数器、虚拟机栈、本地方法区)、线程共享区域(堆、方法区)和直接内存。

  • 线程私有区的生命周期和线程相同,随线程启动创建,随线程结束销毁。在JVM内,每个线程都与操作系统的本地线程直接映射,因此这部分内存区域的存在与否和本地线程的启动和销毁对应。
  • 线程共享区随虚拟机的启动而创建,随虚拟机的关闭而销毁。
  • 直接内存也叫堆外内存,它并不是JVM运行时数据区的一部分,但在并发编程中被频繁使用。JDK的NIO模块提供的基于Channel和Buffer的IO操作就是基于堆外内存实现的,NIO模块通过调用Native函数库直接在操作系统上分配堆外内存,然后使用DirectByteBuffer对象作为这块内存的引用对内存进行操作,Java进程可以通过堆外内存技术避免在Java堆和Native堆中来回复制数据带来的资源占用和性能消耗,因此堆外内存在高并发场景下被广泛使用。

程序计数器

  • 一块很小的内存空间,用于存储当前运行的线程所执行的字节码的行号指示器。
    每个运行中的线程都有一个独立的程序计数器,在方法正在执行时,该方法的程序计数器记录的是实时虚拟机字节码指令的地址,如果该方法执行的是Native方法,则程序计数器的值为空(Undefined)。
  • 属于线程私有的,是唯一没有内存溢出的区域。

虚拟机栈

  • 描述Java方法的执行过程的内存模型,它在当前帧栈中存储了局部变量表、操作数栈、动态链接、方法出口等信息。同时帧栈用来存储部分运行时数据及其数据结构,处理动态链接方法的返回值和异常分派。
  • 帧栈用来记录方法的执行过程,在方法被执行时虚拟机会为其创建一个与之对应的帧栈,方法的执行和返回对应帧栈在虚拟机栈中的入栈和出栈。无论方法是正常运行完成还是异常完成(抛出了未被捕获的异常),都视为方法结束运行。

本地方法区

  • 和虚拟机栈作用类似,区别是虚拟机栈为执行Java方法服务,本地方法区为Native方法服务。

  • 在JVM运行过程中创建的对象和产生的数据都存储在堆中,堆是被线程共享的内存区域,也是垃圾收集器进行垃圾回收的最主要的内存区域。
  • 由于现代JVM采用分代收集算法,因此Java堆从GC的角度还可分为:新生代、老年代和永久代。

方法区

  • 也被称为永久代,用于存储常量、静态变量、类信息、即时编译器编译后的机器码、运行时常量池等数据。
  • JVM把GC分代收集扩展至方法区,即使用Java堆的永久代来实现方法区,这样JVM的垃圾收集器就可以像管理Java堆一样管理这部分内存。永久代的内存回收主要针对常量池的回收和类的卸载,因此可回收对象很少。
  • 常量被存储在运行时常量池中,是方法区的一部分。静态变量也属于方法区的一部分,在类信息(class文件)中不但保存了类的版本、字段、方法、接口等描述信息,还保存了常量信息。
  • 在即时编译后,代码的内容将在执行阶段(类加载完成后)被保存在方法区的运行时常量池中。Java虚拟机堆class文件每一部分的格式都有明确规定,只有符合规范的class文件才能通过检查然后被加载、执行。

JVM运行时内存

JVM的运行时内存也叫做JVM堆,从GC角度更将其分为新生代,老年代和永久代。
其中新生代默认占1/3堆空间,老年代默认占2/3堆空间,永久代占非常少的堆空间。
新生代又分为Eden区、ServivorFrom区和ServivorTo区,Eden区默认占8/10新生代空间,ServivorFrom区和ServivorTo区默认分别占1/10新生代空间。

在这里插入图片描述

新生代

JVM新创建的对象(除了大对象外)会被存放在新生代,默认占1/3堆内存空间。由于JVM会频繁创建对象,所以新生代会频繁触发MinorGC进行垃圾回收。新生代又分为Eden区,ServivorFrom区和ServivorTo区,如下所述:
(1)Eden区:Java新创建的对象首先会被存放在Eden区,如果新创建的对象属于大对象,则直接将其分配到老年代。大对象的定义和具体的JVM版本、堆大小和垃圾回收策略有关,一般为2KB~128KB,可通过XX:PretenureSizeThreshold设置其大小。在Eden区的内存空间不足时会触发MinorGC,对新生代进行一次垃圾回收。
(2)ServivorTo区:保留上一次MinorGC时的幸存者。
(3)ServivorFrom区:将上一次MinorGC时的幸存者作为这一次MinorGC的被扫描者。
新生代的GC过程叫做MinorGC,采用复制算法实现,具体过程如下:
(1)把在Eden区和ServivorFrom区中存活的对象复制到ServivorTo区,如果某对象的年龄达到老年代的标准,则将其复制到老年代,同时把这些对象的年龄加1。如果ServivorTo区的内存空间不够,则也直接将其复制到老年代。如果对象属于大对象,则也直接复制到老年代。
(2)清空Eden区和ServivorFrom区中的对象。
(3)将ServivorFrom区和ServivorTo区互换,原来的ServivorTo区成为下一次GC时的ServivorFrom区。


老年代

老年代主要存放有长生命周期的对象和大对象,老年代的GC叫MajorGC。在老年代,对象比较稳定,MajorGC不会频繁触发。在进行MajorGC前,JVM会进行一次MinorGC,过后仍然出现老年代空间不足或无法找到足够大的连续内存空间分配给新创建的大对象时,会触发MajorGC进行垃圾回收,释放JVM的内存空间。
Major采用标记清除算法,该算法首先会扫描所有对象并标记存活的对象,然后回收未被标记的对象,并释放内存空间。
因为要先扫描老年代的所有对象再回收,所以MajorGC的时间较长。容易产生内存碎片,在老年代没有内存空间可分配时,会出现内存溢出异常。


永久代

永久代指内存的永久保存区域,主要存放Class和Meta(元数据)的信息。Class在类加载时被放入永久代。永久代和老年代、新生代不同,GC不会在程序运行期间对永久代的内存进行清理,这也导致了永久代的内存会随着加载的Class文件的增加而增加,在加载的Class文件过多时会出现内存溢出异常,比如Tomcat引用jar文件过多导致JVM内存不足而无法启动。
在Java8中,永久代已经被元数据区取代。元数据区的作用和永久代类似,二者最大的区别在于:元数据区并没有使用虚拟机的内存,而是直接使用操作系统的本地内存。因此元空间的大小不受JVM内存的限制,只和操作系统的内存有关。
在Java8中,JVM将类的元数据放入本地内存中,将常量池和类的静态常量放入Java堆中,这样JVM能够加载多少元数据信息就不再由JVM的最大可用内存空间决定,而由操作系统的实际可用内存空间决定。


垃圾回收与算法

如何确定垃圾

Java采用引用计数法和可达性分析来确定对象是否应该被回收。引用计数法容易产生循环引用的问题,可达性分析通过根搜索算法实现。根搜索算法以一系列GC Roots的点作为起点向下搜索,在一个对象到任何GC Roots都没有引用链相连时,说明其已经死亡。根搜索算法主要针对栈中的引用、方法区的静态引用和JNI中的引用展开分析。

引用计数法

在Java中如果要操作对象,就必须先获取该对象的引用,因此可以通过引用计数法来判断一个对象是否可以被回收。在为对象添加一个引用时,引用计数加1;在为对象删除一个引用时,引用计数减1;如果一个对象的引用计数为0,则表示此刻该对象没有被引用,可以被回收。引用计数法容易产生循环引用问题,循环引用指两个对象相互引用,导致它们的引用一直存在,而不能被回收。

可达性分析

为了解决引用计数法的循环引用问题,Java还采用了可达性分析来判断对象是否可以被回收。具体做法是首先定义一些GC Roots对象,然后以这些GC Roots对象作为起点向下搜索,如果在GC Roots和一个对象之间没有可达路径,则称该对象是不可达的。不可达对象要经过至少两次标记才能判断其是否可被回收,如果两次标记后该对象仍然不可达,则将被垃圾回收器回收。


Java中常用的垃圾回收算法

标记清除算法
基础的垃圾回收算法,过程分为标记和清除两个阶段。在标记阶段标记所有需要回收的对象,在清除阶段清除可回收的对象并释放所占用的内存空间。由于标记清除算法在清理对象所占用的内存空间后并没有重新整理可用的内存空间,因此如果内存中可被回收的小对象居多,则会引起内存碎片化问题,继而引起大对象无法获得连续可用空间的问题。

复制算法
为了解决标记清除算法的内存碎片化问题而设计,复制算法首先将内存划分为两块大小相等的内存区域,即区域1和区域2,新生成的对象都被存放在区域1中,在区域1内的对象存储满后会对区域1进行一次标记,并将标记后仍然存活的对象全部复制到区域2,这时区域1将不存在任何存活的对象,直接清理整个区域1的内存即可。

复制算法的内存清理效率高且易于实现,但由于同一时刻只有一个内存区域可用,因此存在大量的内存浪费。同时在系统中有大量长时间存活的对象时,这些对象将在内存区域1和内存区域2之间来回复制而影响系统的运行效率。因此该算法只在对象为“朝生夕死”时效率较高。

标记整理算法
结合了标记清除算法和复制算法的优点,其标记阶段和标记清除算法的标记阶段相同,在标记完成后将存活的对象移到内存的另一端,然后清除该端的对象并释放内存。

分代收集算法
无论是标记清除算法,复制算法还是标记整理算法,都无法对所有类型(长生命周期、短生命周期、大对象、小对象)的对象进行垃圾回收。因此针对不同的对象类型,JVM采用了不同的垃圾回收算法即分代收集算法。

分代收集算法根据对象的不同类型将内存划分为不同区域,JVM将堆划分为新生代和老年代。新生代主要存放新生成的对象,其特点是对象数量多但是生命周期短,在每次进行垃圾回收时都有大量的对象被回收;老年代主要存放大对象和生命周期长的对象,因此可回收对象较少。

目前大部分JVM在新生代都采用复制算法,因为在新时代中每次进行垃圾回收都有大量对象被回收,需要复制的对象(存活的对象)较少,不存在大量对象在内存中来回复制的问题,因此复制算法能安全高效地回收新生代大量短生命周期对象并释放内存。JVM将新生代进一步划分为一块较大地Eden区和两块较小地Servivor区,Servivor区又分为ServivorTo和ServivorFrom区。JVM在运行过程中主要使用Eden区和ServivorFrom区,进行垃圾回收时会将这两个区域中存活地对象复制到ServivorTo中,并清理这两个区域的内存空间。

老年代主要存放生命周期较长的对象和大对象,因而每次只有少量非存活的对象被回收,因而在老年代采用标记清除算法。在永久代主要回收废弃的常量和无用的类。

JVM内存中的对象主要被分配到新生代的Eden区和ServivorFrom区,少数情况下会直接分配到老年代。在新生代的Eden区和ServivorFrom区内存空间不足时会触发一次MinorGC,在MinorGC后,Eden区和ServivorFrom区存活的对象会被复制到ServivorTo区,然后Eden区和ServivorFrom区被清理。如果此时ServivorTo区无法找到连续的内存空间存储某个对象,则将这个对象直接存储到老年代。若Servivor区的对象经过一次GC后仍然存活,则年龄加1,默认情况下对象年龄到达15时将被移到老年代。


Java中的引用类型

在Java中一切皆对象,对象的操作是通过该对象的引用实现的,Java中的引用类型有四种:

  • 强引用
    在Java中最常见的就是强引用,在把一个对象赋给一个引用变量时,这个引用变量就是一个强引用。有强引用的对象一定为可达性状态,所以不会被垃圾回收机制回收。因此强引用是造成Java内存泄漏的主要原因。
  • 软引用
    软引用通过SoftReference类实现。如果一个对象只有软引用,则在系统内存空间不足时该对象被回收。
  • 弱引用
    弱引用通过WeakReference类实现,如果一个对象只有弱引用,则在垃圾回收过程中一定被回收。
  • 虚引用
    虚引用通过PhantomReference类实现,虚引用和引用队列联合使用,主要用于跟踪对象的垃圾回收状态。

分代收集算法和分区收集算法

分代收集算法
新生代主要存储短生命周期对象,因此只需要选用复制算法将少量存活对象复制到内存的另一端并清理原内存区域即可。

老年代主要存放长生命周期对象和大对象,可回收对象一般较少,因此JVM采用标记清除/整理算法进行垃圾回收,直接释放死亡状态的对象所占用的内存空间即可。

分区收集算法
分区算法将整个堆空间划分为连续的大小不同的小区域,对每个小区域都单独进行内存使用和垃圾回收,优点是可根据每个小区域内存的大小灵活使用和释放内存。

分区收集算法可根据系统可接受的停顿时间,每次都快速回收若干个小区域的内存,以缩短垃圾回收时系统停顿的时间,最后以多次并行累加的方式逐步完成整个内存区域的垃圾回收。如果垃圾回收机制一次回收整个堆内存,则需要更长时间的系统停顿时间,长时间的系统停顿将影响系统运行的稳定性。


垃圾收集器

在这里插入图片描述


Serial垃圾收集器
基于复制算法实现,是单线程的,在它正在进行垃圾回收时必须暂停其他所有工作线程直到垃圾收集结束。简单高效,对于单CPU运行环境来说没有线程交互开销,可获得最高的单线程垃圾收集效率,因此Serial垃圾收集器是JVM运行在Client模式下的新生代的默认垃圾收集器。

ParNew垃圾收集器
是Serial的多线程实现,同样采用了复制算法,采用多线程模式工作,除此之外和Serial收集器几乎一样。ParNew垃圾收集器在垃圾收集过程中会暂停所有其他工作线程,是JVM运行在Server模式下的新生代的默认垃圾收集器。
默认开启与CPU同等数量的线程进行垃圾回收,在Java应用启动时可通过-XX:ParakkekGCThreads参数调节其工作线程数。

Parallel Scavenge垃圾收集器
为提高新生代垃圾收集效率而设计,基于多线程复制算法,在系统吞吐量上有很大优化,可以更高效地利用CPU尽快完成垃圾回收。通过自适应调节策略提高系统吞吐量,提供了三个参数用于调节、控制垃圾回收的停顿时间及吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMills,控制吞吐量大小的-XX:GCTimeRatio,控制自适应调节策略开启与否的UseAdaptiveSizePolicy

Serial Old垃圾收集器
Serial垃圾收集器的老年代实现,采用单线程执行,不同的时SerialOld针对老年代长生命周期的特点基于标记整理算法实现,是JVM运行在Client模式下老年代默认的垃圾收集器。新生代的Serial垃圾收集器可和老年代的Serial Old垃圾收集器搭配使用,分别针对JVM的新生代和老年代进行垃圾回收,在新时代采用Serial垃圾收集器基于复制算法进行垃圾回收,未被回收的对象在老年代被Serial Old基于标记整理算法进行垃圾回收。

Parallel Old垃圾收集器
采用多线程并发进行垃圾回收,基于标记整理算法实现,优先考虑系统吞吐量,其次考虑停顿时间等因素,如果系统对吞吐量要求较高可优先考虑新生代的Parallel Scavenge垃圾收集器和老年代的Parallel Old垃圾收集器搭配使用。新生代基于Parallel Scavenge垃圾收集器的复制算法进行垃圾回收,老年代基于Parallel Old的标记整理算法进行垃圾回收。

CMS垃圾收集器
为老年代设计,主要目的是最短的垃圾回收停顿时间,基于多线程的标记清除算法,工作机制相对复杂,包含如下四个步骤:
(1)初始标记:只标记和GC Roots直接关联的对象,速度很快,需要暂停所有工作线程。
(2)并发标记:和用户线程一起工作,执行GC Roots的跟踪标记过程,不需要暂停工作线程。
(3)重新标记:在并发标记过程中用户线程继续执行导致在垃圾回收过程中部分对象状态发生改变,为了确保这部分对象状态正确性,需要对其重新标记并暂停工作线程。
(4)并发清除:和用户线程一起工作,执行清除GC Roots不可达对象的任务,不需要暂停工作线程。

CMS垃圾收集器和用户线程一起工作时不需要暂停用户线程,有效缩短了垃圾回收时系统的停顿时间,同时由于CMS垃圾收集器和用户线程一起工作,其并行度和效率也有很大提升。

GI垃圾收集器
为了避免全区域垃圾收集引起的系统停顿,将堆内存划分为大小固定的几个独立区域,独立使用这些区域的内存资源并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,在垃圾收集过程中根据系统允许的最长垃圾收集时间,优先回收垃圾最多的区域。

G1垃圾收集器通过内存区域独立划分使用和根据不同优先级回收各区域垃圾的机制,确保了在有效时间内获得最高的垃圾收集效率,相对CMS,G1有两处突出改进:(1)基于标记整理算法,不产生内存碎片(2)可以精确控制停顿时间,在不牺牲吞吐量的前提下实现短停顿垃圾回收。


Java网络编程模型

阻塞I/O模型
阻塞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模型中,用户线程需要不断询问内核数据是否就绪,在内存数据还未就绪时,用户线程可以处理其他任务,在内核数据就绪后可以立即获取数据并进行相应操作。典型的非阻塞IO模型一般如下:

	while(true){
		data=socket.read();
		if(data==true){//内核数据就绪
			//获取并处理内核数据
			break;
		}else{
			//内核数据未就绪,处理其他任务
		}
	}

多路复用I/O模型
多路复用I/O模型是多线程并发编程用的较多的模型,NIO就是基于多路复用I/O模型实现的,在多路复用I/O模型中会有一个被称为Selector的线程不断轮询多个Socket的状态,只有在Socket有读写事件时,才会通知用户线程进行I/O读写操作。

因为在多路复用I/O模型中只需要一个线程就可以管理多个Socket(阻塞I/O模型和非阻塞I/O模型需要为每个Socket建立一个单独的线程处理该Socket上的资源),并且在真正有Socket读写事件时才会使用操作系统的I/O资源,大大节省了系统资源。

NIO在用户的每个线程中都通过selector.select()查询当前通道是否有事件到达,如果没有则用户线程会一直阻塞。而多路复用I/O模型通过一个线程管理多个Socket通道,在Socket有读写事件时才会通知用户线程。因此多路复用模型在连接数众多且消息体不大的情况下有很大优势。当事件响应体(消息体)很大时Selector线程就会成为性能瓶颈,导致后续的事件迟迟得不到处理影响下一轮的事件轮询。实际应用中,在多路复用的方法体内一般不建议做复杂逻辑运算,只做数据的的接收和转发,将具体的业务操作转发给后面的业务线程处理。


信号驱动I/O模型
在信号驱动I/O模型中,在用户线程发起一个I/O请求操作时,系统会为该请求对应的Socket注册一个信号函数,然后用户线程可以继续执行其他业务逻辑,在内核数据就绪时,系统会发送一个信号到用户线程,用户线程在接收到该信号后,会在信号函数中调用对应的I/O读写操作完成实际的I/O请求操作。


异步I/O模型
在异步I/O模型中用户线程会发起一个asynchronous read操作到内核,内核在接收到该请求后会立刻返回一个状态,来说明请求是否成功发起,在此过程中用户线程不会发生任何阻塞。接着内核会等待数据准备完成并将数据复制到用户线程中,在数据复制完成后内核会发送一个信号到用户线程,通知其读操作已经完成。在异步I/O模型中,用户线程不需要关心整个I/O操作是如何进行的,只需发起一个请求,在接收到内核返回的成功或失败信号时说明I/O操作已经完成,直接使用数据即可。

在信号驱动模型中,用户线程接收到信号表示数据已经准备就绪,在异步I/O模型中,收到信号便表示I/O操作已经完成,可以开始使用数据。


Java I/O
在整个Java.io包中最重要的是5个类和1个接口,5个类是File、OutputStream、InputStream、Reader、Writer,接口是Serializable。


Java NIO
NIO的实现主要设计三大核心内容:Selector选择器,Channel通道和Buffer缓冲区。Selector用于监听多个Channel的事件,比如连接打开或数据到达,因此一个线程可以实现对多个数据Channel的管理。传统I/O居于数据流进行读写操作,而NIO基于Channel和Buffer进行读写操作,并且数据总是从Channel读取到Buffer中,或者从Buffer写入到Channel中。

NIO和传统IO的区别如下:

  • IO是面向流的,NIO是面向缓冲区的。在面向流的操作中,数据只能在一个流中连续进行读写,数据没有缓冲,因此字节流无法前后移动。而在NIO中每次都是将数据从一个Channel读取到一个Buffer中,再从Buffer写入Channel中,因此可以方便地在缓冲区中进行数据的前后移动等操作。该功能在应用层主要用于数据的粘包、拆包等操作,在网络环境不可靠的环境下尤为重要。
  • 传统IO的流操作是阻塞模式的,NIO的流操作是非阻塞模式的。在传统IO下,用户线程在调用read()和write()进行读写操作时该线程将一直被阻塞,知道数据读取或完全写入。NIO通过Selector监听Channel上事件的变化,在Channel上有数据发生变化时通知该线程进行读写操作。对于读请求,有可用数据时进行Buffer的读操作,没有数据时线程可以执行其他业务操作。对于写操作,有数据时进行Channel的异步写入操作,用户线程不需要等待整个数据被完全写入目标Channel就可以继续执行其他业务逻辑。

Channel和IO中的Stream流类似,只不过流是单向的,而Channel是双向的,既可以读又可以写。

Buffer实际上是一个容器,其内部通过一个连续的字节数组存储I/O上的数据,在NIO中,Channel在文件、网络上对数据的读取或写入都必须经过Buffer。

Selector用于检测在多个注册的Channel上是否有I/O事件发生,并对检测到的I/O事件进行相应的处理。因此通过一个Selector可以实现对多个Channel的管理,不必为每一个连接都创建一个线程,避免线程资源的浪费和多线程之间的上下文切换导致的开销。同时Selector只有在Channel上有读写事件发生时才会调用I/O函数进行读写操作,可极大减少系统开销提高并发量。

实例:
客户端:

class MyServer{
    private ServerSocketChannel serverSocket;//服务端socket
    private ByteBuffer byteBuffer;//缓冲区
    private Selector selector;//选择器
    private int remoteClientNum;//客户端连接数

    public MyServer(int port){
        try{//初始化channel监听
            initChannel(port);
        }catch (IOException e){
            e.printStackTrace();
            System.exit(-1);
        }
    }

    public static void main(String[] args) {
        try{
            MyServer myServer=new MyServer(9999);
            myServer.listen();
        }catch (Exception e){
            e.printStackTrace();
        }
    }

    public void initChannel(int port) throws IOException {
        //打开channel
        serverSocket=ServerSocketChannel.open();
        //设置为非阻塞模式
        serverSocket.configureBlocking(false);
        //绑定端口
        serverSocket.bind(new InetSocketAddress(port));
        System.out.println("正在监听端口: "+port);
        //创建selector
        selector=Selector.open();
        //向selector注册channel
        serverSocket.register(selector, SelectionKey.OP_ACCEPT);
        //分配缓冲区大小
        //缓冲区大小
        int size = 1024;
        byteBuffer=ByteBuffer.allocate(size);
    }

    private void listen() throws Exception{
        while (true){
            //获取处于就绪状态的channel数量
            int n=selector.select();
            if(n==0)
                continue;
            //每个selector对应多个SelectionKey,每个SelectionKey对应一个channel
            Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
            while (iterator.hasNext()){
                SelectionKey key = iterator.next();
                //如果处于连接就绪状态则开始接收客户端的连接
                if(key.isAcceptable()){
                    //获取channel
                    ServerSocketChannel server= (ServerSocketChannel) key.channel();
                    //接收连接
                    SocketChannel channel=server.accept();
                    //channel注册
                    registerChannel(selector,channel,SelectionKey.OP_READ);
                    //客户端连接数
                    remoteClientNum++;
                    System.out.println("在线连接数量="+remoteClientNum);
                    write(channel,"hello client".getBytes());
                }
                //如果管道处于读就绪状态
                if(key.isReadable()){
                    read(key);
                }
                iterator.remove();
            }
        }
    }

    private void registerChannel(Selector selector, SocketChannel channel, int opRead) throws IOException {
        if(channel==null)
            return;
        channel.configureBlocking(false);
        channel.register(selector,opRead);
    }

    private void write(SocketChannel channel, byte[] bytes) throws IOException {
        byteBuffer.clear();
        byteBuffer.put(bytes);
        byteBuffer.flip();//读模式转为写模式
        channel.write(byteBuffer);
    }

    private void read(SelectionKey key) throws IOException {
        SocketChannel socketChannel= (SocketChannel) key.channel();
        int count;
        byteBuffer.clear();
        //从channel读取数据到buffer
        while((count=socketChannel.read(byteBuffer))>0){
            byteBuffer.flip();//写模式转读模式
            while(byteBuffer.hasRemaining()){
                System.out.print((char)byteBuffer.get());
            }
            byteBuffer.clear();
        }
        if(count<0){
            socketChannel.close();
        }
    }
}


客户端:

class MyClient{
    private ByteBuffer byteBuffer;//缓冲区
    private SocketChannel socket;
    
    public void connectServer() throws IOException {
        socket=SocketChannel.open();
        socket.connect(new InetSocketAddress("127.0.0.1",9999));
        socket.configureBlocking(false);
        int size = 1024;
        byteBuffer=ByteBuffer.allocate(size);
        receive();
    }

    public static void main(String[] args) throws IOException {
        new MyClient().connectServer();
    }

    private void receive() throws IOException {
        while (true){
            byteBuffer.clear();
            int count;
            while ((count=socket.read(byteBuffer))>0){
                byteBuffer.flip();
                while(byteBuffer.hasRemaining()){
                    System.out.print((char)byteBuffer.get());
                }
                send2Server("say hi".getBytes());
                byteBuffer.clear();
            }
        }
    }

    private void send2Server(byte[] bytes) throws IOException {
        byteBuffer.clear();
        byteBuffer.put(bytes);
        byteBuffer.flip();
        socket.write(byteBuffer);
    }
}

先启动服务端,再启动客户端,服务端收到客户端的“say hi”,客户端收到服务端的“hello client”:
在这里插入图片描述
在这里插入图片描述


JVM的类加载机制

JVM的类加载分为5个阶段:加载、验证、准备、解析、初始化。在类初始化完成后就可以使用该类的信息,在一个类不再被需要时可以从JVM中卸载。
在这里插入图片描述
加载
指JVM读取Class文件,并且根据文件描述创建Class对象的过程。类加载过程主要包含将Class文件读取到运行时数据区的方法区内,在堆中创建Class对象,并封装类在方法区的数据结构的过程,在读取Class文件时既可以通过文件的形式读取,也可以通过jar包、war包读取,还可以通过代理自动生成 Class或其他方式读取。

验证
主要用于确保Class文件符合当前虚拟机的要求,保障虚拟机自身的安全,只有通过验证的Class文件才能被JVM加载。

准备
主要工作是在方法区中为类变量分配内存空间并设置类中变量的初始值。初始值指不同数据类型的默认值,这里需要注意final类型的变量和非final类型的ianl在准备阶段的数据初始化过程不同。比如一个成员变量定义如下:
public static long value=1000;以上代码中,静态变量value在准备阶段的初始值为0,将value设置为1000是在对象初始化时完成的,因为JVM在编译阶段会将静态变量的初始化操作定义在构造器中。但是如果将value声明为final类型:public static final long value=1000;则JVM在编译阶段后会为final类型的变量value生成其对应的ConstantValue属性,虚拟机在准备阶段会根据该属性将value赋值1000。

解析
JVM会将常量池中的符号引用替换为直接引用。

初始化
主要通过执行类构造器的<client>方法为类进行初始化,该方法是在编译阶段由编译器自动收集类中静态语句块和变量的赋值操作组成的。JVM规定,只有在父类的<client>方法都执行成功后,子类的<client>方法才可以被执行。在一个类中既没有静态变量赋值操作也没有静态语句块时,编译器不会为该类生成<client>方法。
以下情况JVM不会执行类的初始化流程:

  • 常量在编译时会将其常量值存入使用该常量的类的常量池中,该过程不需要调用常量所在的类,因此不会触发该常量类的初始化
  • 在子类引用父类的静态字段时,不会触发子类的初始化,只会触发父类的初始化
  • 定义对象数组,不会触发该类的初始化
  • 在使用类名获取Class对象时不会触发类的初始化
  • 在使用Class.forName()加载指定的类时,可以通过initialize参数设置是否需要初始化
  • 在使用ClassLoader默认的loadClass方法加载类时不会触发该类的初始化

类加载器
JVM提供了三种类加载器,分别是启动类加载器、扩展类加载器、和应用程序类加载器。
在这里插入图片描述

  • 启动类加载器
    负责加载Java_HOME/lib目录中的类库,或通过-Xbootclasspath参数指定路径中被虚拟机认可的类库
  • 扩展类加载器
    负责加载Java_HOME/lib/ext目录中的类库,或通过java.ext.dirs系统变量加载指定路径中的类库
  • 应用程序类加载器
    负责加载用户路径上的类库
  • 自定义类加载器
    通过继承java.lang.ClassLoader类实现

双亲委派机制
JVM通过双亲委派机制对类进行加载,双亲委派机制指一个类在收到类加载请求后不会尝试自己加载这个类,而是把该类加载请求向上委托给其父类去完成,其父类在接收到该类加载请求后又会将其委派给自己的父类,以此类推,这样所有的类加载请求都被向上委派到启动类加载器中。若父类加载器在接收到类加载请求后发现自己也无法加载该类(通常由于该类的Class文件在父类的类加载路径中不存在),则父类会将该信息反馈给子类并向下委派子类加载该类直到该类被成功加载,若找不到该类会抛出ClassNotFound异常。

双亲委派机制的类加载流程如下:
(1)将自定义类加载器挂载到应用程序类加载器
(2)应用程序类加载器将类加载请求委托给扩展类加载器
(3)扩展类加载器将类加载请求委托给启动类加载器
(4)启动类加载器在加载路径下查找并加载Class文件,如果未找到目标Class文件,交给扩展类加载器加载
(5)扩展类加载器在加载路径下查找并加载Class文件,如果未找到目标Class文件,交给应用程序类加载器加载
(6)应用程序类加载器在加载路径下查找并加载Class文件,如果未找到目标Class文件,交给自定义类加载器加载
(4)在自定义类加载器下查找并加载Class文件,如果未找到目标Class文件,则抛出ClassNotFound异常。

双亲委派机制的核心是保障类的唯一性和安全性。例如在加载rt.jar包中的java.lang.Object时,无论是哪个类加载器加载这个类,最终都将类加载请求委托给启动类加载器加载,这样就保障了类加载的唯一性。如果在JVM中存在包名和类名都相同的两个类,则该类无法被加载,JVM也无法完成类加载流程。

OSGI
Open Service Gateway Initiative是Java动态化模块系统的一系列规范,旨在为实现Java程序的模块化编程提供基础条件。基于OSGI的程序可以实现模块级的热插拔功能,在程序升级更新时,可以只针对需要更新的程序进行停用和重新安装,极大提高了系统升级的安全性和便捷性。

OSGI提供了一种面向服务的架构,该架构为组件提供了动态发现其他组件的功能,这样无论加入或卸载组件,都能被系统其他组件感知以便各个组件之间更好协调工作。

OSGI不但定义了模块化开发规范,还定义了实现这些规范所依赖的服务与架构,市场上也有成熟的框架对其进行实现和应用,但只有部分应用适合采用OSGI方式,因为它为了实现动态模块化,不再遵循JVM类加载双亲委派机制和其他规范,在安全性上有所牺牲。

发布了66 篇原创文章 · 获赞 302 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/qq_41112238/article/details/105033873