深入浅出Java虚拟机

深入浅出Java虚拟机


什么是Java虚拟机

虚拟机是一种抽象化的计算机,通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。Java虚拟机屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码字节码,就可以在多种平台上不加修改地运行。

特性:

  • 跨平台:Java虚拟机屏蔽了平台差异性,只要在某个平台上实现了Java虚拟机,Java程序就可以运行
  • 可移动:Java字节码被设计为面向流,指令采用了栈式计算机字节码编码方式
  • 安全性:Java字节码二进制程序运行前,首先要通过字节码验证器的验证,验证未通过的程序会被拒绝执行。

Java字节码是一种低级别、类似汇编语言的程序设计语言,是Java语言编译的目标语言,能够有效隔离高层语言和底层体系结构间的巨大差异,并屏蔽底层体系结构的细节。Java字节码指令集是一种抽象的栈式计算机指令集。总之,Java虚拟机是能够读取和解析Java字节码文件、运行Java字节码程序的软件系统。

1.Java虚拟机架构


在这里插入图片描述

  1. 类加载子系统:把Java字节码文件加载到虚拟机内部,在虚拟机内部有专门的存储区(称为“方法区”)来存放加载的类。进一步完成类的验证、准备、解析、初始化等操作。采用了动态加载和动态链接的机制,虚拟机在运行过程中会随时加载所需要的类,并把加载进来的类整合到虚拟机内部数据结构中。
  2. 执行引擎:执行Java字节码和本地代码
  3. 本地方法接口:负责Java字节码和本地代码之间的交互,使得Java程序能够复用大量现有的本地库,提高Java编程的便利性
  4. 异常处理:允许程序员能简洁处理程序运行中出现的各种错误和异常情况
  5. 堆存储子系统:负责管理Java的对象堆。完成:高效管理内存、为堆分配的对象选择合理的对象数据结构编码、自动垃圾收集
函数如何调用:出入Java栈

Java栈是线程私有的内存空间,Java堆和程序数据相关,栈和线程执行相关。

每一次函数调用都会有一个对应的栈帧被压入栈,每一次函数调用结束都会有一个对应的栈帧被弹出栈。函数有两种返回方式:return正常返回,抛出异常。会占用栈空间,如果请求的栈深度大于最大可用栈深度,就会抛出栈溢出错误。比如:函数嵌套调用的层次很大程度上由栈大小决定,栈越大,支持的函数嵌套调用次数越多。

每一个栈帧,至少包含局部变量表、操作数栈和帧数据区。

局部变量表:保存函数参数以及局部变量,如果函数参数以及局部变量较多,会使局部变量表膨胀,使每一次函数调用就会占用更多的栈空间,导致函数嵌套调用次数减少。也是重要的垃圾回收根节点,被局部变量表中直接或者简介引用的对象是不会被回收的

操作数栈:保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。

帧数据区:保存常量池的指针,方便程序访问它。

识别方法区

方法区是所有线程共享的内存区域,保存系统的类信息,在jdk6,7可以理解为永久区,在jdk8以后被彻底移除,替代的是元数据区,这是一块堆外的直接内存。

2.类加载器-Class装载系统


类加载子系统负责把Java字节码文件加载到Java虚拟机中,将Java类文件转换为Java虚拟机内部对类的数据结构表示,对类进行验证、准备、解析和初始化等。

类的装载条件:class文件只有在必须要使用的时候才会被加载,JVM不会无条件的装载class类型,一个类或者接口在初次使用前,必须进行初始化。

类装载几个阶段:
  1. 加载类,读取Java字节码二进制类文件,对类文件格式进行解析并进行语法分析,编译成类的虚拟机内部数据结构表示,创建Class类的实例,表示该类型

  2. 类的验证:基于严格的语义验证规则,对Java类的合法性进行校验,如果类的验证不能通过,虚拟机将直接拒绝执行该类

    必须判断类的二进制数据是否符合格式要求,比如是否以魔数0xCAFEBABE开头,版本号是否在JVM支持范围内,然后进行语义检查,和字节码验证,以及符号引用的验证。

  3. 准备阶段:对类中的字段和方法分别按合理的方式进行组织和存储,为类的静态字段分配空间和赋予默认值

  4. 解析:解析类的常量池,把相应的符号解析成对应实体的引用,将符号引用转为直接引用,得到类或者字段、方法在内存中的指针或者偏移量,如果直接引用存在,可以肯定系统中存在该类、方法或者字段。在java代码中直接使用字符串常量时,就会在类中出现CONSTANT_String的解析,并且会引用一个CONSTANT_UTF8的常量项,在JVM内部运行时的常量池中,会维护一张字符串拘留表intern,保存所有出现过的字符串常量,并且没有重复项。使用String.intern方法可以得到一个字符串在拘留表中的引用,所有纸面相同的字符串的String.intern方法的返回值总是相等的。

  5. 初始化:完成对类的初始化方法的调用。如果前面步骤都没问题,表示类可以顺利装载到系统中,此时类才会开始执行Java字节码,执行类的初始化方法clinit,是由编译器自动生成的,由类静态成员的赋值语句以及static语句块共同产生的。在加载一个类之前,虚拟机总会试图加载该类的父类,因此父类clinit方法总是在子类的clinit方法之前被调用。当多个下了车试图初始化同一个类时,只有一个线程可以进入clinit方法,其他线程必须等待,如果之前的线程成功加载了类,则等在队列中的线程就没有机会再执行clinit方法了,由于方法是带锁线程安全的,所以在多线程环境下进行类初始化时,可能会引起死锁,而且很难发现。

例子:

系统类的加载过程:

main.java 编译---->main.class,执行 java main。

类加载就是把main.class类加载到虚拟机中,从磁盘复制到虚拟机内存中,形成合理的数据结构。

  1. 类main引用了字符串类String,系统类System,这些也要加载
  2. 类main继承Object,
  3. String,System,Object可能还引用了其他类,这些也要加载
  4. 加载类对象
  5. 加载实现的接口
  6. 加载不存在具体的字节码文件类的实体

方法区:设计合理的数据结构来存储加载进来的类

三类数据:代码区、运行时常量池、类辅助数据结构

代码区:存储类的相关信息以及方法代码,支持三种不同类形式:文件加载类、数组类、基本类

另一种自定义类加载器模型:双亲委派模型,与独立加载模型有3个显著区别:

  1. 虚拟机所加载的系统标准类库都来自同一个位置,所有的类加载都会先委派到当前类加载器的双亲加载器,处于最根部的加载器或虚拟机内置的类加载器会从某个默认的系统类路径中加载系统类,包括Object类
  2. 被双亲委派模型加载的类,在虚拟机的方法区只存在一份
  3. 所有的用户自定义类加载器对象形成了一个树状结构
ClassLoader类加载器

主要工作在Class装载的加载阶段,作用是从系统外部获得Class二进制数据流。

是Java的核心组件,所有的class都是由ClassLoader进行加载的,负责通过各种方式将class信息的二进制数据读入系统,然后交给JVM进行连接、初始化等操作。因此,ClassLoader在整个装载阶段,只能影响类的加载,无法改变类的连接和初始化行为。ClassLoader是一个抽象类,提供了一些重要接口,用于自定义class的加载流程和加载方式,其中有一个字段parent,是一个ClassLoader的实例,表示ClassLoader的双亲,在类加载过程中,ClassLoader可能会将某些请求交给自己的双亲处理。

ClassLoader分类

JVM会创建3类ClassLoader为整个应用程序服务,分别是BootStrapClassLoader(启动类加载器), ExtensionClassLoader(扩展类加载器), AppClassLoader(应用类加载器),每一个应用程序可以拥有自定义的ClassLoader.

ClassLoader的层次结构:自顶向下为启动类加载器、扩展类加载器、应用类加载器和自定义加载器,其中启动类加载器的双亲为扩展类加载器,扩展类加载器的双亲为启动类加载器,当系统需要使用一个类时,在判断类是否已经被加载时,会从底层类加载器开始进行判断,这个委托路线是单向的。当系统需要加载一个类时,会从顶层类开始加载,一次向下尝试,直到成功。

启动类加载器最特别,完全由C语言实现,并且在Java中没有对象与之对应,也就是说在任何启动类加载器中加载的类是无法获得其ClassLoader实例的,比如String属于Java核心类,会被启动类加载器加载,String.class.getClassLoader()返回Null。系统的核心类就是由启动类加载器进行加载的,也是虚拟机的核心组件。扩展类加载器和应用类加载器都有对应的Java对象可用。

在JVM设计中,使用分散的ClassLoader装载类是有好处的,不同层次的类可以由不同的ClassLoader加载,从而进行划分,有助于系统的模块化设计。

一般来说,启动类加载器负责加载系统的核心类,比如rt.jar中的java类,扩展类加载器用于加载JAVA_HOME下lib目录中的java类,应用类加载器和自定义加载器用于加载用户类

在这里插入图片描述

ClassLoader的双亲委派模式

系统中的ClassLoader在协同工作时,默认使用双亲委派模式,在类加载时,系统会判断当前类是否已经加载,如果已经被加载,就会字节返回可用的类,否则就会尝试加载,会先请求双亲处理,如果请求失败,则自己加载。如果双亲为null,则使用启动类加载,如果双亲加载不成功,由当前ClassLoader加载。

双亲为null有两者情况:双亲就是启动类加载器、当前加载器就是启动类加载器。

ClassLoader的双亲委派模式的弊端

检查类是否已经加载的委托过程是单向的,上层的ClassLoader无法访问下层的ClassLoader所加载的类。解决:在Thread类存在两个方法:getContextClassLoader和setContextClassLoader,这两个方法分别是取得设置在线程中的上下文加载器和设置一个线程的上下文加载器,通过这两个方法,可以把一个ClassLoader置于一个线程实例中,使该ClassLoader成为一个相对共享的实例,默认情况下,上下文加载器就是应用类加载器,这样启动类加载器中的代码也可以通过这种方式访问应用类加载器的类。

热替换的实现

在程序运行过程中,不停止服务,只通过替换程序文件来修改程序的行为。关键需求在于服务不能中断,修改必须立即表现在正在运行的系统中,java不是天生支持热替换,如果一个类已经加载到系统中,通过修改类文件无法让系统再来加载并重定义这个类,在java中实现这个功能可行的方法就是灵活应用ClassLoader,由不同的ClassLoader加载同名类属于不同类型,不能相互转化和兼容。

热替换基本思路:

在这里插入图片描述

3.垃圾回收算法

垃圾:内存中不会再被使用的对象

常用垃圾回收算法

引用计数法:实现简单,对于一个对象A,只要任何一个对象引用了A,A的引用计数器加一,失效时就减一。当计数器为0,则对象A就不可能再被使用。

只要为每个对象配备一个整型计数器即可,但是存在两个问题:1.无法处理循环引用,在Java的垃圾回收器中没有使用这个算法

2.引用计数器每次引用产生和消除,伴随的加减法操作对系统性能有影响。

标记清除算法:

分为两个阶段:标记和清除

在标记阶段:首先通过根节点标记所有从根节点开始的可达对象,未被标记的对象就是未被引用的垃圾对象,在清除阶段,清除所有未被标记的对象,最大问题是可能产生空间碎片。回收后的空间是不连续的,在对象的堆空间分配过程中,不连续内存空间的工作效率要低于连续空间。

复制算法:

将原有的内存空间分为两部分,每次只使用一部分,在进行垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。复制算法的效率很高,并且没有空间碎片,代价是将系统内存折半。

改进的复制算法:

在新生代串行垃圾回收器中,使用复制算法思想,新生代分为eden区、from区、to区3个部分,from和to可视为用于复制两块大小相同、地位相等且可进行角色互换的空间,from和to也称为survivor区,用于存放未被回收的对象。

新生代:存放刚刚创建的或者经历垃圾回收次数不多的年轻对象的堆空间

老年代:存放经历多次垃圾回收后依然存活的对象的堆空间

在进行垃圾回收时,eden区存活对象会被复制到未使用的survivor区(假设to区),正在使用的survivor区(假设from区)的年轻对象也复制到to区,大对象或者老年对象会直接进入老年代,此时eden区和from区的剩余对象就是垃圾对象,可以清空,to 区存放此次回收后存活对象,保证了空间的连续性,又避免大量的内存空间浪费。

标记压缩算法

是一种老年代的回收算法,在标记清除法的基础上做了优化,也是从根节点触发,对所有可达对象做一次标记,将所有的存活对象压缩到内存一端,清理边界外所有空间,避免了碎片产生,又不需要两块相同的内存空间。

分代算法

将内存区间根据对象特点分为几块,使用不同的回收算法,提高回收效率。一般的话,JVM将所有新建对象放入新生代的内存区域,大多数很快被回收,垃圾对象多于存活对象,适合复制算法;当一个对象经历几次回收后依然存活,就进入老年代的内存空间,可以认为这些对象在一段时间内。甚至在整个程序的生命周期中将常驻内存。因此适合采取标记压缩算法。通常新生代回收频率很高,但是每次耗时短,老年代回收频率低,但是每次耗时长。为了支持高频率的新生代回收,JVM使用卡表的数据结构,是一个比特位集合,每一个比特可以用来表示老年代的某一区域中的所有对象是否持有新生代对象的引用,这样在新生代垃圾回收时,不用花费大量时间扫描所有的老年代对象来确认每一个对象的引用关系,可以先扫描卡表,只有当卡表的标记位为1时,才需要扫描给定区域的老年代对象,为0的老年代对象,一定不含有新生代对象的引用。因此,在新生代GC时只需要扫描卡表位为1的老年代空间,加快回收速度。

垃圾的判断可触及性

如果从所有根节点开始都无法访问某个对象,说明这个对象不再使用,应该被回收,但是事实上,一个无法触及的对象可能在某个条件下复活,对他的回收就是不合理的。

可触及性3中状态:

可触及的:从根节点开始,可以达到这个对象

可复活的:对象的所有引用都被释放,但是对象有可能在finalize函数中复活

不可触及的:对象的finalize函数被调用,并且没有复活,进入不可触及状态,不能被复活,因为finalize函数只能使用一次。

不建议使用finalize函数释放资源:有可能发生引用外泄,无意中复活对象,它是被系统调用的,调用时间不明确,不是一个好的资源释放方案。

4个级别引用:

强引用:对象可触及,在任何时候都不会被回收,可以直接访问目标对象,可能导致内存泄漏

软引用:当堆空间不足时会被回收

弱引用:在系统GC时,只要发现它,不管系统堆空间如何,都会被回收

虚引用:它和没有引用几乎是一样的,随时都可能被回收,必须和引用队列一起使用,作用在于跟踪垃圾回收过程。当垃圾回收器准备回收一个对象时,发现它还有虚引用,就会在回收对象后,将这个虚引用加入引用队列,通知应用程序对象的回收情况。

垃圾回收时的停顿现象

垃圾回收器的任务是识别和回收垃圾对象,以进行内存清理。为了让垃圾回收器可以正常且高效执行,会要求系统进入一个停顿的状态,目的是终止所有线程的执行,这样系统才不会有新的垃圾产生,同时保证了系统状态在某一个瞬间的一致性,有益于垃圾回收器更好的标记垃圾对象,因此在垃圾回收时,都会产生应用程序的停顿,整个应用程序没有任何响应,也叫stop-the-world,STW。

4.垃圾回收器和内存分配

新生代串行回收器

指使用单线程进行垃圾回收的回收器,只有一个工作线程,是独占式的垃圾回收方式,在串行回收器进行垃圾回收时,Java应用程序中的线程都需要暂停工作,等待垃圾回收完成。不适合在实时性要求较高的场景。JVM在Client模式下,是默认的垃圾回收器

老年代串行回收器

它使用的是标记压缩法,由于老年代通常需要比新生代垃圾回收更长的时间,一旦老年代串行回收器启动,应用程序可能会停顿很久。

并行回收器

在串行回收器做了改进,使用多个线程同时进行垃圾回收,可有效减少GC时间。

新生代ParNew回收器

只是简单将串行回收器多线程化,也是独占式回收器,在回收过程中应用程序会全部暂停。在并发能力强的CPU上,它产生的停顿时间短于串行回收器,在单CPU或者并发能力较弱的系统中,效果不会比串行回收器好

新生代Parallel回收器

它使用复制算法,和ParNew回收器一样,都是多线程、独占式的回收器,非常关注系统的吞吐量。支持自适应的GC调节策略,新生代的大小、eden区和survivor区的比例等参数会自动调整,达到在堆大小、吞吐量和停顿时间的平衡点。

老年代Parallel回收器

和新生代Parallel回收器一样,是多线程并发的回收器,使用标记压缩法,非常关注吞吐量,可以在对吞吐量敏感的系统中使用。

CMS回收器

意为并发标记清除,它主要关注系统停顿时间,工作主要步骤:初始标记(标记根对象)、并发标记(标记所有对象)、预清理(为正式清理做准备和检查)、重新标记、并发清除和并发重置(垃圾回收完成后,重新初始化CMS数据结构和数据,为下一次垃圾回收做准备)。初始标记和重新标记独占系统资源,并发标记、预清理、并发清除和并发重置是可以和用户线程一起执行的。不是独占式的,可以在应用程序运行过程中进行GC。

在CMS回收过程中,应用程序仍然在工作,又会不断产生垃圾,这些垃圾在当前CMS回收过程是无法清除的,所以应该确保应用程序有足够的内存可用。因此CMS回收器不会等待堆内存饱和时才进行垃圾回收,而是当堆内存使用率达到某一域值便开始回收,确保在回收过程有足够的空间支持应用程序运行。这个回收域值默认是68%,当老年代的空间使用率达到68%时,执行一次CMS回收。如果内存增长缓慢,可以设置一个稍大的值,大的域值可以有效降低CMS触发频率,减少老年代回收次数,明显改善程序性能,如果内存使用率增长很快,则应该降低这个域值,以避免触发老年代串行回收器。域值调节可以使用参数CMSInitiatingQccupancyFraction进行调优。CMS是一个基于标记清除算法,会产生大量内存碎片,可以设定在进行多少次CMS回收后,进行一次内存压缩。

G1回收器

是在jdk1.7正式使用的全新垃圾收集器,为了取代CMS收集器,依然属于分代垃圾回收器,区分老年代和新生代,但是从堆结构上看,不要求整个eden区、年轻代或者老年代都连续,使用了全新的分区算法。具有如下特点:

  • 并行性:在回收期间,可以由多个GC线程同时工作,有效利用多核计算能力
  • 并发性:拥有与应用程序交替执行的能力,不会在整个回收期间完全阻塞应用程序
  • 分代GC:和之前回收器不同,它兼顾年轻代和老年代
  • 空间整理:在回收过程中,会进行适当的对象移动,不像CMS,只是简单的标记清除对象,他在若干次GC后,CMS必须进行一次碎片整理,但是G1每次回收都会有效的复制对象,减少碎片空间
  • 可预见性:由于分区,G1可以只选取部分区域进行内存回收,缩小了回收的范围,全局停顿得到更好的控制

G1将堆进行分区,每次回收的时候,只回收其中几个区域,能控制垃圾回收产生的一次停顿时间

GC4个阶段:

  • 新生代GC
  • 并发标记周期
  • 混合回收
  • 如果有需要,可能进行fullGC

G1的新生代GC

新生代GC的主要工作是回收eden区和survivor区,一旦eden区被占满,新生代GC就会启动,回收后的所有eden区都会被清空,survivor区会被回收一部分数据,老年代会增多,因为部分survivor区或者eden区的对象可能会晋升到老年区。

G1的并发标记周期

可分为以下几步:

  1. 初始标记
  2. 根区域扫描
  3. 并发标记
  4. 重新标记
  5. 独占清理
  6. 并发清理

混合回收

在并发标记周期中,虽然有对象被回收,但是总体来说回收的比例很低,但是在这之后,G1已经明确知道哪些区域含有比较多的垃圾对象,在混合回收阶段就可以专门针对这些区域进行回收。G1会优先回收垃圾比例高的区域,这也是G1名字由来,全称为GarbageFirstGarbageCollector,垃圾优先的垃圾回收器。在这个阶段即会执行正常的年轻代GC,又会选取一些被标记的老年代区域进行回收,同时处理了新生代和老年代。由于新生代GC,eden区会被清空,另外垃圾比例高的区域会被清理,剩余存活的对象会被移动到其他区域,可以减少空间碎片。

对象何时进入老年代

一般而言,当对象首创时,会被放在新生代的eden区,如果没有GC介入,这些对象不会离开eden区。当对象的年龄到达一定大小,就可以进入老年代,对象的年龄是由经历过的GC次数决定的,经历的每一次GC,如果没有被回收,它的年龄就加1,虚拟机提供了一个参数来控制新生代对象的最大年龄,MaxTenuringThreshold。默认情况下,这个参数的值是15,也就是说新生代的对象最多经历过15次GC,就可以进入老年代。实际晋升年龄是根据survivor区的使用情况动态计算而来,MaxTenuringThreshold只是表达这个年龄最大值。

除了年龄,对象的体积也会影响对象的晋升,如果对象体积很大,新生代无论eden区还是survivor区都无法容纳这个对象,就非常有可能直接放在老年代。另外也有参数PretenureSizeThreshold, 用来设置对象直接晋升老年代的域值,只要对象的大小大于指定值,就会绕过新生代,直接在老年代分配,这个参数只对ParNew有效,默认情况下值为0,也就是不指定最大晋升大小,一切由运行情况决定。

在TLAB上分配对象

线程本地分配缓存,是一个线程专用的内存分配区域,可以加速对象分配,由于对象一般分配在堆上,堆是全局共享的,同一时间可能会有多个线程在堆上申请空间,因此每一次对象分配都必须进行同步,在竞争激烈的情况下,分配的效率又会降低,但是分配对象是Java最常用的操作,因此JVM使用了TLAB这种宣传专属的区域来避免多线程冲突,提高分配效率。TLAB本身占用eden区的空间,在TLAB启用的情况下,虚拟机会为每一个Java线程分配一块TLAB区域。由于TLAB区域一般不会太大,因此大对象不会在此区域分配,总是直接分配在堆上。

finalize()函数对垃圾回收的影响

Java中提供了类似于C++析构函数的机制–finalize函数,在Object中声明,该函数允许在子类中被重载,用于在对象被回收时进行资源释放,不过尽量不要使用它,因为:finalize函数可能会导致对象复活、finalize函数的执行时间是没有保障的,完全由GC线程决定,在极端情况下,若不发生GC,finalize函数将没有机会执行、可能会影响GC性能

finalize函数是由FinalizerThread线程处理的,每一个即将被回收并且包含finalize函数的对象都会在正式回收前加入FinalizerThread的执行队列,该队列为ReferenceQueue引用队列,内部为链表结构,队列中每一项为Finalizer引用,本质为一个引用,Finalizer内部封装了实际的回收对象,链表的字段referent指向实际的对象引用,由于对象在回收前被Finalizer的referent字段进行强引用,并加入了FinalizerThread的执行队列,这意味着对象又变为可达对象,因此阻止了对象的正常回收。由于在引用队列中的元素排对执行finalize函数,一旦出现性能问题,将导致垃圾对象长时间堆积在内存中,肯能会导致OOM。

5.分析Java堆

OOM内存溢出

堆溢出

由于大量的对象都被直接分配在堆上,它是最有可能发生溢出的空间。绝大部分Java内存溢出都属于这个情况,大量持有强引用的对象无法回收,当对象大小之和大于由Xmx参数指定的堆空间大小时,就发生溢出。为了减少堆溢出错误,可以使用Xmx参数指定一个更大的堆空间,也可以通过VisualVM工具找到大量占用堆空间的对象并在应用程序做出合理优化。

直接内存溢出

可以通过Java代码获得一块堆外的的内存空间,这是直接向操作系统申请的,直接内存的申请速度一般比堆内存慢,但是访问速度快于堆内存,因此对于那些可复用的,并且会被经常访问的空间,使用直接内存可以提高系统性能,但是由于没有被Java虚拟机完全托管,若使用不当,会触发直接内存溢出。直接内存不一定能够触发GC,除非直接内存使用量达到了MaxDirectMemorySize的设置值,所以保证内存不溢出的方法时合理进行FullGC的执行,或者设定一个系统可达的MaxDirectMemorySize值,这样实际上不会触发内存溢出,默认情况下等于Xmx的值,因此如果系统的堆内存少有GC发生,而直接内存申请频繁,会比较容易导致直接内存溢出。

过多线程导致OOM

由于每一个线程的开启都要占用系统内存,因此线程数量太多也会导致OOM,由于线程的栈空间是在堆外分配的,如果想让系统支持更多的线程,就要使用较小的堆,操作系统就可以预留更多内存用于线程创建。或者可以减少线程的栈空间。但是减小了线程的栈空间大小,也会容易导致栈溢出。

字符串在虚拟机的实现

String特点:

不变性:String对象一旦生成,则不能再对它进行改变,可以泛化为不变模式,当以对象需要被多个线程共享并且频繁访问,可以省略同步和锁等待时间,从而提高系统性能。

针对常量池优化:当两个String对象拥有相同的值时,它们只引用常量池中的同一个副本,可以节省内存空间

类的final定义:String类在系统中不能有任何子类,是对系统安全性的保护。使用final定义有助于帮助虚拟机寻找机会,内联所有final方法,从而提高系统效率。

String的内存泄露

内存泄漏:程序未能释放不再使用的对象占据的内存,从而导致内存不断减少,最终导致内存溢出。

String类主要由三部分组成:value数组、offset偏移、count长度,这个结构为内存泄漏创造条件,字符串的实际内容由三者共同决定。如果value数组包含100个字符,而count长度只有1个字节,那么String实际上只有一个字符,但是占据了至少100个字节,剩余的99个就属于泄漏,它们不会被使用和释放,却长期占用内存,直到字符串本身被收回。在jdk1.7中,被修改,去掉了offset和count两项,String实质内容仅由value决定

String常量池的位置

虚拟机中,有一块专门用于存放字符串常量的区域叫常量池,在jdk1.6之前属于永久区,1.7之后被移到堆中管理。String.intern获得常量池中的字符串引用,如果常量池没有该字符串,该方法将字符串加入常量池,然后将引用放入list进行持有,确保不被回收。尽管String.intern的返回值永远等于字符串常量,但不代表在系统每时每刻,相同的字符串的intern返回值都是一样的,因为存在一种可能,在一次Intern调用之后,该字符串在某一个时刻被回收,再进行一次Intern调用,那么字面量相同的字符串重新被加入到常量池,但是引用位置不同。

6.锁与并发

对象头和锁

在java虚拟机的实现中每一个对象都有一个对象头,用于保存对象的系统信息,其中有一个MarkWord部分,是实现锁的关键。它是一个多功能的数据区,可以存放对象的哈希值、对象年龄、锁的指针等信息,一个对象是否占用锁、占用哪个锁就记录在MarkWord

锁在JVM的实现和优化

在多线程程序中,线程之间的竞争不可避免,如果将所有的线程竞争都叫由操作系统处理,那么并发性能非常低下,因此虚拟机在操作系统层面挂起线程之前,会先尽力解决竞争关系。

偏向锁

是jdk1.6的优化方式,如果程序没有竞争,则取消之前已经取得锁的线程同步操作,某一线程获取锁后,就会进入偏向模式,当线程再请求这个锁时,无须再进行相关操作,从而节省操作时间。如果有其他线程进行了锁请求,则锁退出偏向模式,JVM可以使用UseBiasedLocking设置启用偏向锁。偏向锁在竞争少的情况下,对系统性能有一定帮助,在锁竞争激烈的场景没有太强的优化效果,因为大量的竞争会导致持有锁的线程不停的切换,锁很难一致保持在偏向模式,反而会降低系统性能。因此在禁止激烈可以禁用偏向锁。

轻量级锁

如果偏向锁失败,JVM会让线程申请轻量级锁,在内部使用BasicObjectLock对象实现,这个对象内部由一个BasicLock对象和一个持有该锁的Java对象指针组成,BasicObjectLock对象放置在Java栈的栈帧中,在BasicLock对象内部还维持着displcaed_header字段,用于备份对象头部的MarkWord,BasicObjectLock对象的obj字段指向该对象。BasicLock通过set_displcaed_header方法备份原对象的MarkWord,接着使用CAS操作,尝试将BasicLock的地址复制到对象头的MarkWord,如果复制成功,那么加锁成功,否则轻量级锁可能膨胀为重量级锁。轻量级锁处理失败后,废弃前面BasicLock备份的对象头信息,然后正式启用重量级锁,首先通过inflate方法进行锁膨胀,目的是获得对象的ObjectMonitor,然后使用enter方法尝试进入该锁。在调用enter方法时,线程可能会在操作系统层面被挂起,此时线程间切换和调度的成本较高。

自旋锁

锁膨胀后,进入ObjectMonitor的enter方法,线程可能会在操作系统层面被挂起,这样线程上下文切换的性能损失较大,在锁膨胀后,虚拟机会进行最后争取,希望线程可以尽快进入临界区避免被操作系统挂起,此时就需要自旋锁。可以使线程在没有取得锁时不被挂起,而去执行一个空循环,即自旋锁,经历若干空循环后,线程如何可以获得锁则继续执行,否则被挂起。使用自旋锁后,线程被挂起的概率减小,执行的连贯性相对加强,对于那些锁竞争不是很激烈,锁占用时间很短的并发线程具有积极效果。但是对于锁竞争激烈,单线程锁占用时间长的并发程序,自旋锁在自旋等待后,往往依然无法获得锁,不仅拜拜浪费CPU时间,还避免不了被挂起,浪费了系统资源。

锁消除

锁消除时Java虚拟机在编译时,通过对上下文扫描,去除不可能存在共享资源竞争的锁,可以节省无意义的请求锁时间。

锁在应用层的优化

减少锁持有时间

在使用锁进行并发控制的程序中,单个线程对锁的持有时间与系统性能有直接关系,线程持有锁时间越长,锁的竞争越激烈,因此应该尽可能减少对某个锁的占用时间,减少线程间的互斥时间。如果并发量很大,较好的解决方案是:只在必要时进行同步,就能明显减少线程持有锁的时间,有助于减小锁冲突可能性,提高系统的吞吐量。

减小锁粒度

指缩小锁定对象的范围,从而减小锁冲突的可能性,也是一种削弱多线程锁竞争的有效手段,使用场景就是ConcurrentHashMap类的实现,很好的使用了拆分锁对象的方式提高ConcurrentHashMap的吞吐量,将整个HashMap分为若干段,每个段就是一个子HashMap,如果需要增加一个新的表项,并不是将整个HashMap加锁,而是首先根据hashcode获得该表项应该放到哪个段中,然后对段加锁,完成put操作,在多线程环境中,如果多个线程同时进行put操作,只要被加入的表项不存放在同一个段中,线程间就可以做到真正并行。默认情况下,ConcurrentHashMap拥有16个段,可以接收16个线程同时插入,从而提高吞吐量。

但是减小锁粒度带来新问题:当系统需要取得全局锁时,消耗的资源比较多,虽然put方法很好分离了锁,但是试图访问ConcurrentHashMap全局信息时,就需要同时取得所有段的锁才能顺利实施,比如size()方法,返回ConcurrentHashMap的有效表项数量,就要获取全部有效表项之和。尽管size会先使用无锁方式求和,如果失败才会尝试加锁方法,但是在高并发场合,ConcurrentHashMap的size方法性能要差于同步的HashMap。

锁分离

是减小锁粒度的特例,将一个独占锁分成多个锁,比如LinkedBlockQueue的实现。take和put方法分别实现了从队列中获得数据和往队列中增加数据的功能,两个操作分别在队列的队头和队尾,理论上两者无冲突。如果使用独占锁,则要求两个操作进行时获取当前队列的独占锁,那么take和put操作就不可能真正并发,在运行时,它们会彼此等待对方释放锁资源,从而影响程序在高并发的性能。在Jdk中,用两把不同的锁分离了take和put操作,它们之间不存在锁竞争关系,从而实现了读取数据和写数据的分离,使两者实现真正意义上的可并发操作。

锁粗化

如果对同一个锁不停的进行请求、同步和释放,本身也会消耗系统资源,反而不利于性能优化。因此,JVM在遇到一连串连续的对同一锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减少对锁的请求同步次数,这叫锁粗化。尤其是在循环内请求锁时,将同步操作放在循环外,随着循环次数增加,性能优化效果越明显。

性能优化就是根据运行时的真实情况对各个资源点进行权衡的过程,锁粗化的思想和减少锁持有时间是相反的,在不同的场合使用不同的锁。

无锁

在高并发时,对锁的激烈竞争可能会成为系统瓶颈,可以使用非阻塞的同步方法,不需要锁,依然能够确保数据和程序在高并发环境下保持一致性。使用基于CAS(compare and swap)算法的无锁并发控制方法,它对死锁问题天生免疫,并且线程的相互影响比基于锁的方式小,完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,比基于锁的方式拥有更优越的性能。

CAS算法:包含三个参数,形式:CAS(V,E,N),V表示要更新的变量,E表示预期值,N表示新指,仅当V的值等于E的值时,才会将V的值设为N,如果V和E的值不相同,说明其他线程做了更新,当前线程什么都不做,最后CAS返回当前V真实值。CAS操作是乐观态度进行的,它总是认为自己可以成功完成操作,当多个线程同时使用CAS操作一个变量时,只有一个会胜出并成功更新,其余均会失败。失败的线程不会被挂起,仅仅被告知失败,并且允许再次尝试,或者放弃操作。基于这样,CAS操作即使没有锁,也可以发现其他线程对当前线程的干扰,进行响应处理。为了让CAS算法被Java程序充分使用,在JUC包下的atomic包下,有一组使用无锁算法实现的原子操作类比如AtomicInteger,它的核心方法以getAndSet方法为例,在CAS算法中,首先是一个无穷循环,用于多线程间的冲突处理,在当前线程受其他线程影响而更新失败时,会不停尝试,直到成功。get方法用于取得当前值,并使用compareAndSet方法进行更新,如果未收到其他线程影响,则预期值就等于当前值,若更新成功就会退出循环。如果受到其他线程影响,就会更新失败,进行下一次循环,尝试继续更新,直到成功。在整个更新过程中,无需加锁,无需等待,无锁的操作实际上将多线程并发冲突处理交由应用层解决。不仅提升了系统性能,还增加了系统灵活性。

LongAdder

无锁的原子类操作使用系统的CAS算法指令,有远超锁的性能,但是还可以更进一步,在jdk1.8中引入LongAdder类,也在JUC的atomic包下,也是使用了CAS算法指令。由于原子类的实现机制是它们都在一个死循环内,不断尝试修改目标值,直到修改成功,如果竞争不激烈,那么修改成功的概率很高,否则修改失败的概率很高,在大量修改失败时,这些原子操作就会进行多次循环尝试,因此性能会受到影响。LongAdder思想:仿造ConcurrentHashMap,将热点数据分离,可以将AtomicInteger的内部核心数据value分离成一个数组,每个线程访问时,提高哈希等算法映射到其中一个数字进行计数,最终的计数结果为这个数组的求和累加。LongAdder进行了优化,热点数据分离成多个cell,每个cell独自维护内部的值,当前对象的实际值由所有的cell累计合成,这样热点就进行了有效的分离,提高了并行度。

Java内存模型JMM

并发程序必须解决的问题:多线程间的数据访问一致性,一旦出现多个线程访问某个变量的值不一致的情况,系统可能出现奇怪的问题。Java内存模型JMM就是用来解释规范这种情形的。

基本原则:

原子性:原子操作不可中断,也不能被多线程干扰,比如int和byte等数据的复制操作具备原子性,比如a++操作不具备原子性,它涉及读取a,计算新值和写入a三步操作。中间有可能被其他线程干扰,导致最终计算结果和实际值出现偏差。在32位虚拟机中,对Long和double的赋值和读取不是原子操作,因为Long和double是64位,在并发环境下,可能出现一个线程写long型数的高32位,另一个线程写低32位,可以在声明long变量时,加volatile关键字,可以确保基本的原子性。

有序性:在指令执行时可能会出现对目标指令重排,导致和预期情况不符合。使用synchronized关键字

可见性:当一个线程修改了一个变量的值时,在另外一个线程中可以马上得知这个修改。由于系统编译器优化,部分变量的值可能会被寄存器或高速缓冲cache缓存,每个CPU都拥有独立的寄存器和cache,从而导致其他线程无法立即发现这个修改。可以增加volatile和synchronize关键字解决线程的可见性问题。

7.Class文件结构

对于JVM而言,Class文件是一个重要接口,无论使用何种语言进行软件开发,只要能把源文件编译为正确的Class文件,就可以在JVM上运行,可以说class文件时JVM的基石。

class文件

从JVM角度看,通过class文件可以让更多的计算机语言支持JVM平台,不仅是JVM的执行入口,更是JAVA生态圈的基础和核心

在这里插入图片描述

class文件的结构严格按照结构体的定义:文件以一个4字节的magic(魔数)开头,紧跟着大小版本号,在版本号后面是常量池,常量池的个数为constant_pool_count,常量池之后是类的访问修饰符,代表自身类的引用、父类引用以及接口数量和实现的接口引用,然后是有字段的数量和字段描述、方法数量以及方法描述等,最后是类文件的属性信息。

class文件的标志:魔数

用来告诉JVM,这是一个class文件,4字节的无符号整数,固定为0xCAFEBABE

常量池:存放所有常数

是class文件内容最丰富的区域,是整个class文件的基石。

8.常用JVM参数

跟踪垃圾回收:-XX:+PrintGC

类加载、卸载的跟踪: -verbose:class

查看系统参数:-XX:+PringtVMOptions

堆配置参数-让性能飞起来

java进程启动时,虚拟机会分配一块初始堆空间,使用参数 -Xms指定这块空间大小,如果初始堆空间耗尽,虚拟机将会对堆空间进行扩展,上限为最大堆空间,参数为-Xmx

新生代配置:-Xmn设置新生代大小,一般为整个堆空间的1/3-1/4,-XX:Survivor设置新生代中eden取和from/to区的比例

9.字节码执行

Java字节码对于虚拟机,就像汇编语言对于计算机,属于基本执行指令,每一个Java字节码指令是一个byte数字,并且有一个对应的助记符,大约有200个字节码指令。方法的java字节码被编译到java方法的code属性中,如果指令具体内容,可用jdk自带工具javap工具。

在这里插入图片描述

一个程序经过反编译后会生成大量信息,首先会生成class文件的Java源文件名称、小版本号和大版本号,还会显示类中所有常量等。

Java虚拟机常用指令
常量入栈指令:

const系列

push系列:包括bipush和sipush,区别在于接受数据类型不同,bipush接受8位整数,sipush接受16位整数,都将参数压入栈

万能的ldc指令:可以接受一个8位参数,该参数指向常量池中的int, float或String类型的索引

出栈装入局部变量表指令

用于将操作数栈顶元素弹出后,装入局部变量表的指定位置,用于给局部变量赋值,以store形式存在。

同步控制

为了实现多线程同步,JVM还提供了monitorenter, moniterexit来完成临界区的进入和离开操作。当一个线程进入同步块时,使用monitorenter指令请求进入,如果当前对象的监视器计数器为0,他被允许进入,如果为1,则判断持有当前监视器的线程是否为自己,如果是,则进入,否则进行等待,直到对象的监视器计数器为0,才会被允许进入同步块。当线程退出同步块时,需要使用moniterexit声明退出。在JVM中,任何对象都有一个监视器与之关联,用来判定对象是否被锁定,当监视器被持有后,对象处于锁定状态。指令monitorenter, moniterexit在执行时,都需要在操作数栈顶压入对象,之后monitorenter, moniterexit传递锁定和释放都是针对这个对象的监视器进行的。

型的索引

出栈装入局部变量表指令

用于将操作数栈顶元素弹出后,装入局部变量表的指定位置,用于给局部变量赋值,以store形式存在。

同步控制

为了实现多线程同步,JVM还提供了monitorenter, moniterexit来完成临界区的进入和离开操作。当一个线程进入同步块时,使用monitorenter指令请求进入,如果当前对象的监视器计数器为0,他被允许进入,如果为1,则判断持有当前监视器的线程是否为自己,如果是,则进入,否则进行等待,直到对象的监视器计数器为0,才会被允许进入同步块。当线程退出同步块时,需要使用moniterexit声明退出。在JVM中,任何对象都有一个监视器与之关联,用来判定对象是否被锁定,当监视器被持有后,对象处于锁定状态。指令monitorenter, moniterexit在执行时,都需要在操作数栈顶压入对象,之后monitorenter, moniterexit传递锁定和释放都是针对这个对象的监视器进行的。

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值