问题集锦-JVM与多线程

JVM-Class类文件结构

常量池:字面量(字符串和final常量)和符号引用(类和接口的全限定名、字段的名称和描述符、方法句柄和方法类型、方法的名称和描述符 )
字段表、方法表、属性表(code属性存放代码)

JVM-运行时数据区域

方法区(线程共享,可能会内存溢出):用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
1、以前使用永久代来实现方法区导致Java应用更容易遇到内存溢出的问题,现在使用本地内存来实现方法区
2、JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出(移到堆内)
3、在JDK 8,完全废弃了永久代,使用本地内存中实现的元空间来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中
4、运行时常量池:Class文件中常量池在类加载后存放到方法区的运行时常量池中(也存放直接引用)
堆(线程共享,可能会内存溢出):
1、存放对象实例, “几乎”所有的对象实例都在这里分配内存(由于即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配、标量替换优化手段已经导致对象可以不在堆内分配)
2、堆中可以划分出多个线程私有的分配缓冲区 ,以更好地回收内存,或者更快地分配内存
3、通过参数-Xmx和-Xms设定堆内存大小
直接内存(可能会内存溢出):
1、直接内存并不是虚拟机运行时数据区的一部分,也不是Java中的内存区域
2、应用:NIO是一种基于通道(Channel)与缓冲区 (Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer对象作为这块内存的引用进行操作,这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据(零拷贝)
栈(线程私有,可能会内存溢出和堆栈溢出):
1、每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法返回地址等信息
2、每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程
局部变量表(栈帧中,大小在编译期已经确定,code属性中存储大小):
1、用于存放方法参数和方法内部定义的局部变量(基本类型和对象引用)、returnAddress 类型(指向了一条字节码指令的地址),这些数据类型在局部变量表中的存储空间以局部变量槽来表示
2、当一个方法被调用时,使用局部变量表来完成实参到形参的传递
3、如果执行的是实例方法(没有被static修饰的方法),局部变量表中第0位存放this引用,在方法中直接访问
4、局部变量表中的变量槽是可以重用的;复用的时机是其他变量需要用到这块变量槽,因此即使该变量已经不被使用,也不会垃圾回收,除非赋值null(不建议,会被优化掉)
5、局部变量不像类变量那样存在“准备阶段”,类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予代码定义的初始值,因此即使在初始化阶段代码没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值,不会产生歧义;但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值,那它是完全不能使用的
操作数栈(栈帧中,大小在编译期已经确定,code属性中存储大小):
1、两个栈帧会出现一部分重叠,让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递
动态连接(栈帧中):
1、每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接
2、Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数,这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析;另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接
方法返回地址(栈帧中):方法退出时使用
程序计数器(线程私有,不会内存溢出):字节码解释器工作时通过改变计数器的值来选取下一条需要执行的字节码指令(线程切换时为了标识每个线程执行位置)
执行引擎:
1、在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,还可能会有同时包含几个不同级别的即时编译器一起工作的执行引擎
2、从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果
方法调用:
1、方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本 (即调用哪一个方法),暂时还未涉及方法内部的具体运行过程
2、Class文件的编译过程中不包含传统程序语言编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是直接引用
3、解析:所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,即调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来,这类方法的调用被称为解析
(静态方法、私有方法、final方法、构造方法、父类方法)
4、分派
**静态分派:重载
引用类型和实际类型在程序中都可能会发生变化,区别是引用类型的变化仅仅在使用时发生,引用类型不会被改变,并且最终的引用类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么

// 实际类型变化 
Human human = (new Random()).nextBoolean() ? new Man() : new Woman(); 
// 引用类型变化 
sr.sayHello((Man) human) ;
sr.sayHello((Woman) human);

虚拟机(或者准确地说是编译器)在重载时是通过参数的引用类型而不是实际类型作为判定依据的,由于引用类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的引用类型决定了会使用哪个重载版本
所有依赖引用类型来决定方法执行版本的分派动作,都称为静态分派,静态分派的最典型应用表现就是方法重载,静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的
**动态分派 覆盖
在运行期根据实际类型确定方法执行版本的分派过程称为动态分派
**单分派和多分配派 重载+覆盖
方法的接收者与方法的参数统称为方法的宗量
单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择
编译阶段:根据方法接收者的引用类型+方法参数类型才能确定使用哪个类的哪个方法,因此静态分派属于多分派类型
运行阶段:根据方法接收者的实际类型就能确定使用哪个类(会覆盖静态分派的判断,使用哪个方法是静态分派确定),因此动态分派属于单分派类型

JVM-类加载机制

定义:Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型的过程
在Java语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的
类的生命周期:加载 、连接(验证、准备、解析)、初始化 、使用和卸载
类加载的时机
对于初始化阶段,有且只有6种情况必须对类进行初始化(主动引用),其余都是被动引用:
1、new对象、读写类型静态字段(final常量除外,编译期已放入常量池)、调用类型静态方法时
2、对类型反射调用时
3、初始化子类时,要先初始化父类
4、虚拟机启动时,初始化主类
5、方法句柄对应的类初始化
6、接口定义了默认方法,实现类初始化时,要先初始化接口
被动引用:
1、通过子类引用父类的静态字段,不会导致子类初始化(对于静态字段, 只有直接定义这个字段的类才会被初始化)
2、通过数组定义来引用类,不会触发此类的初始化(与数组是不同的类)
3、常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化(在编译阶段通过常量传播优化,已经将常量的值直接存储在调用类的常量池中,以后调用类对常量的引用,实际都被转化为对自身常量池的引用了;也就是说,实际上调用类的Class文件之中并没有原类的符号引用入口,这两个类在编译成 Class文件后就已经不存在任何联系了)
类加载的过程
加载:
1、通过类的全限定名获取定义类的二进制字节流(非数组类型的加载既可以使用引导类加载器来完成,也可以由用户自定义的类加载器去完成(重写一个类加载器的findClass或loadClass方法);数组类本身不通过类加载器创建,是由Java虚拟机直接在内存中动态构造出来的,但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型最终还是要靠类加载器来完成加载)
2、将字节流所代表的静态存储结构转化为方法区的运行时数据结构
3、在内存中生成代表类的Class对象,作为方法区中类的各种数据的访问入口
验证:
1、文件格式验证
2、元数据验证(是否有父类、是否继承了不该继承的如final类、是否实现了抽象类的所有方法等)
3、字节码验证:对类的方法体(Class文件中的code属性)进行检验分析
4、符号引用验证:将符号引用转换为直接引用时验证(解析阶段),通过全限定名是否能找到对应的类等
准备:
1、准备阶段是正式为类变量分配内存并设置初始值(默认值)的阶段,这些类变量所使用的内存都在方法区(jdk1.8在堆中)中进行分配
2、实例变量将会在对象实例化时随着对象一起分配在Java堆中
3、类常量在这个阶段会被初始化(不是默认值)
解析:将常量池内的符号引用替换为直接引用
1、符号引用:符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容
2、直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在
初始化:
1、初始化阶段就是执行类构造器方法(所有类变量的赋值动作和静态语句块(static{}块)中的语句,执行顺序是代码中的顺序)的过程,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问
2、父类的类构造器方法先执行,父类中定义的静态语句块要优先于子类的变量赋值操作
3、Java虚拟机必须保证一个类的类构造器方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的类构造器方法,其他线程都需要阻塞等待,直到活动线程执行完毕类构造器方法,如果在一个类的类构造器方法中有耗时很长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的
4、同一个类加载器下,一个类型只会被初始化一次
类加载器
1、类加载器通过一个类的全限定名来获取描述该类的二进制字节流
2、对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间,即比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class文件,被同一个Java虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等
双亲委派模型
1、Java一直保持着三层类加载器、双亲委派的类加载架构
2、三层类加载器:
**启动类加载器:这个类加载器负责加载存放在 \lib目录中的类,启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时, 如果需要把加载请求委派给启动类加载器去处理,那直接使用null代替即可
**扩展类加载器:这个类加载器负责加载\lib\ext目录中的类,由于扩展类加载器是由Java代码实现的,开发时可以直接在程序中使用扩展类加载器来加载Class文件
**应用程序类加载器:这个类加载器负责加载用户类路径(ClassPath)上所有的类库,开发时同样可以直接在代码中使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器
3、各种类加载器之间的层次关系被称为类加载器的双亲委派模型,双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器,不过这里类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父类加载器的代码(高内聚,低耦合)
4、双亲委派模型的工作流程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子类加载器才会尝试自己去完成加载
5、使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系,如类java.lang.Object,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类,反之,如果没有使用双亲委派模型,都由各个类加载器自行去加载的话,如果用户自己也编写了一个名为java.lang.Object的类,并放在程序的 ClassPath中,那系统中就会出现多个不同的Object类,Java类型体系中最基础的行为也就无从保证,应用程序将会变得一片混乱,如果写一个与rt.jar类库中已有类重名的Java 类,将会发现它可以正常编译,但永远无法被加载运行
6、双亲委派模型实现:先检查请求加载的类型是否已经被加载过,若没有则调用父加载器的 loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器,如果父类加载器加载失败, 抛出异常,才调用自己的findClass()方法尝试进行加载
破坏双亲委派模型
1、按照loadClass()方法的逻辑,如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的
2、双亲委派很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载),基础类型之所以被称为“基础”,是因为它们总是作为被用户代码继承、调用的API存在,但程序设计往往没有绝对不变的完美规则,如果有基础类型又要调用回用户的代码,那该怎么办呢?如JDBC使用线程上下文类加载器去加载所需的代码,这是一种父类加载器去请求子类加载器完成类加载的行为,这种行为实际上是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型的一般性原则,但也是无可奈何的事情
3、由于用户对程序动态性(如代码热替换、模块热部署)的追求,需要破坏双亲委派模型;OSGi实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块都有一个自己的类加载器,当需要更换一个程序模块,就把程序模块连同类加载器一起换掉以实现代码的热替换

JVM-对象

对象的创建
1、校验是否能在常量池中定位到一个类的符号引用
2、校验符号引用代表的类是否已被加载、解析和初始化过,如果没有,必须先执行相应的类加载过程
3、为新生对象分配内存,对象所需内存的大小在类加载完成后便可完全确定,使用指针碰撞(连续分配)或空闲列表(记录可用空闲内存)来分配内存(需要处理并发情况下线程安全问题,对分配内存空间的动作进行同步处理(CAS+失败重试)或把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存TLAB,只有本地TLAB用完了,分配新的缓存区时才需要同步锁定)
4、将分配到的内存空间(但不包括对象头)都初始化为零值,保证对象的实例变量可以不赋初始值就可以直接使用
5、设置对象头信息,如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(真正调用Object::hashCode方法时才计算)、对象的GC分代年龄等信息;根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式
6、执行构造函数,初始化实例变量
对象的内存结构
对象头、实例数据、对齐填充
1、对象头:包括两类信息,第一类是用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,即Mark Word,是一个动态定义的数据结构


image.png

第二类是类型指针(如果是句柄就不需要),即对象指向它的类型元数据的指针,通过这个指针可以确定该对象是哪个类的实例
此外,如果对象是一个Java数组,在对象头中还必须有一块用于记录数组长度的数据
2、实例数据:无论是从父类继承下来的,还是在子类中定义的实例变量(不包括静态变量)都必须记录起来
对象的访问定位
通过栈上的reference(句柄和直接指针)数据来操作堆上的具体对象
1、句柄(易于维护):Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息
2、直接指针(速度快,HotSpot使用):在Mark Word中存放类型指针去访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销

JVM-垃圾收集

哪些内存需要回收? Java堆和方法区
方法区的垃圾收集:废弃的常量和不再使用的类型
判定一个类型是否属于“不再被使用的类”:
1、该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
2、加载该类的类加载器已经被回收
3、该类对应的Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
什么时候回收?
引用计数算法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的
很难解决对象之间相互循环引用的问题
可达性分析算法
通过一系列称为“GC Roots”的根对象作为起始节点集,根据引用关系连接其他对象,如果某个对象到GC Roots间不可达,则对象是不可能再被使用的
根对象:
1、在虚拟机栈(栈帧中的本地变量表)中引用的对象,如参数、局部变量、临时变量
2、在方法区中类变量引用的对象
3、在方法区中常量引用的对象
4、在本地方法栈中Native方法引用的对象
5、Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象等,还有系统类加载器
6、所有被同步锁(synchronized关键字)持有的对象
某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性
引用:
1、强引用,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
2、软引用,只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常
3、弱引用,被弱引用关联的对象只能生存到下一次垃圾收集发生为止,当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
4、虚引用,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例
如何回收?分代收集理论
分代收集理论:
1、弱分代假说:绝大多数对象都是朝生夕灭的
2、强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
3、跨代引用假说:跨代引用相对于同代引用来说仅占极少数(针对对象不是孤立的,对象之间会存在跨代引用)(在新生代上建立一个全局的数据结构即“记忆集”,把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用;当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描;虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的)
新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集
老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集,目前只有CMS收集器会有单独收集老年代的行为
混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集,目前只有G1收集器会有这种行为
整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集
如何回收?垃圾收集算法
标记-清除算法(老年代)
首先标记出所有(不)需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象
缺点:
1、执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低
2、内存空间碎片问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作,分配时需要使用空闲分配链表
标记-复制算法(新生代)
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉
如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可
这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费
改进:把新生代分为一块较大的Eden空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor,发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间
需要依赖其他内存区域(老年代)进行分配担保
标记-整理算法(老年代)
标记完对象后,不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存
存活对象太多,移动负担重,对象移动操作必须全程暂停用户应用程序才能进行
从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算
CMS收集器:虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间

JVM-HotSpot垃圾收集算法细节

根节点枚举:
1、固定可作为根节点的主要在全局性的引用(如常量或类变量)与执行上下文(如栈帧中的本地变量表)中
2、所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,但是可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发
3、在HotSpot中,使用OopMap的数据结构记录哪些地方存放着对象引用,HotSpot并不是为每一条指令都生成对应的OopMap(消耗内存空间),而是在安全点(“长时间执行”的地方,如方法调用、循环跳转、异常跳转等都属于指令序列复用,程序会检查垃圾收集标志位,如果需要垃圾收集,主动停顿在这里,等待垃圾收集);但是并不是所有程序都能执行到安全点,如程序“不执行”(就是没有分配处理器时间,如用户线程处于Sleep状态或者Blocked状态)的时候线程无法响应虚拟机的中断请求,就不能再走到安全点去中断挂起自己,虚拟机也显然不可能持续等待线程被唤醒重新获取时间片,再到安全点,因此把这行场景设置为安全区域,当程序执行到这些区域时,会标识自己已经进入了安全区域,当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程,当程序要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那程序就继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止
4、跨代引用问题:记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构(解决跨代引用的问题),最常用的实现形式是卡表(每个记录精确到一块内存区域,该区域内有对象含有跨代指针)(写屏障,类似于切面)
3、并发的可达性分析:如果用户线程与收集器是并发工作,这样可能出现两种后果:一种是把原本消亡的对象错误标记为存活, 只不过产生了一点逃过本次收集的浮动垃圾而已,下次收集清理掉就好;另一种是把原本存活的对象错误标记为已消亡,非常致命
4、当且仅当以下两个条件同时满足时,会产生“对象消失”的问题: 赋值器插入了一条或多条从标记对象到未标记对象的新引用; 赋值器删除了全部从标记中对象到该未标记对象的直接或间接引用(未标记的对象与标记对象新建立了引用关系)
要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可
两种解决方案:
1、增量更新要破坏的是第一个条件,当标记对象插入新的指向未标记对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的标记对象为根,重新扫描一次(CMS)
2、原始快照要破坏的是第二个条件,重新扫描一次指向删除对象所有标记对象(G1)

JVM-垃圾收集器

Serial收集器(新生代)单线程 标记-复制算法
1、进行垃圾收集时,必须暂停其他所有工作线程,直到收集结束
ParNew收集器(新生代)多线程 标记-复制算法 与CMS配合使用
Parallel Scavenge收集器(新生代)多线程 标记-复制算法
1、目标是达到一个可控制的吞吐量
Serial Old收集器(老年代)单线程 标记-整理算法
Parallel Old收集器 (老年代)多线程 标记-整理算法
CMS收集器 (老年代)多线程 标记-清除算法
目标是获取最短回收停顿时间
流程:
1、初始标记:需要停顿时间,仅仅只是标记一下GC Roots能直接关联到的对象,速度很快
2、并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
3、重新标记:需要停顿时间,是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录(增量更新),这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短
4、并发清除:清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的
从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的
缺点:
1、对处理器资源非常敏感,在并发阶段,它虽然不会导致用户线程停顿,但却会因为占用了一部分线程(或者说处理器的计算能力)而导致应用程序变慢,降低总吞吐量
2、无法处理“浮动垃圾”,有可能出现并发失败进而导致另一次完全停顿线程的Full GC的产生
**在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留待下一次垃圾收集时再清理掉
**同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用,因此CMS收集器不能像其他收集器那样等待到老年代几乎完全被填满了再进行收集,必须预留一部分空间供并发收集时的程序运作使用
**要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次并发失败,这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集, 但这样停顿时间就很长了
3、收集结束时会有大量空间碎片产生,空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很多剩余空间,但就是无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC的情况
G1收集器 (全堆)面向局部收集和基于Region(大小相等)的内存布局,可以面向堆内存任何部分来组成回收集进行回收,Region可以作为新生代、老年代,不同阶段可以有不同的角色,G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍
实现中的关键问题:
1、将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?使用记忆集避免全堆作为GC Roots扫描,每个Region都维护有自己的记忆集(浪费内存空间),这些记忆集会记录下别的Region 指向自己的指针,并标记这些指针分别在哪些内存区域
2、在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?原始快照+指针(隔开空闲空间和待收集空间)
流程:
1、初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象,这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿
2、并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行,当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象(原始快照)
3、最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录
4、筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间,这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的
G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region 之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片
缺点:
1、在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比CMS要高
2、就内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)占用更多的内存空间;相比起来CMS的卡表就相当简单, 只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的
3、在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同
**如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行同样的卡表维护操作外,为了实现原始快照搜索 (SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况
**相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点, 但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担,由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理
4、CMS和G1分别使用增量更新和原始快照技术,实现了标记阶段的并发,不会因管理的堆内存变大,要标记的对象变多而导致停顿时间随之增长,但是对于标记阶段之后的处理,仍未得到妥善解决,CMS使用标记-清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法不论如何优化改进,在设计原理上避免不了空间碎片的产生,随着空间碎片不断淤积最终依然逃不过停顿线程的命运;G1虽然可以按更小的粒度进行回收,从而抑制整理阶段出现时间过长的停顿,但毕竟也还是要暂停的
ZGC收集器 (全堆)
几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系
ZGC收集器是一款基于Region内存布局的,(暂时) 不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器
内存布局:
1、与G1一样,ZGC也采用基于Region的堆内存布局,但不同的是,ZGC的Region具有动态性——动态创建和销毁,以及动态的区域容量大小(小型、中型固定)
2、大型Region容量不固定,可以动态变化,每个大型Region中只会存放一个大对象,实际容量完全有可能小于中型Region,大型Region在ZGC的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂
并发整理算法的实现(染色指针技术):
1、从前,如果我们要在对象上存储一些额外的、只供收集器或者虚拟机本身使用的数据,通常会在对象头中增加额外的存储字段,如对象的哈希码、分代年龄、锁记录等就是这样存储的,这种记录方式在有对象访问的场景下是很自然流畅的,不会有什么额外负担
2、但如果对象存在被移动过的可能性,即不能保证对象访问能够成功呢? 又或者有一些根本就不会去访问对象,但又希望得知该对象的某些信息的应用场景呢?能不能从指针或者与对象内存无关的地方得到这些信息,如是否能够看出来对象被移动过?追踪式收集算法的标记阶段就可能存在只跟指针打交道而不必涉及指针所引用的对象本身的场景,如对象标记的过程中需要给对象打上三色标记,这些标记本质上就只和对象的引用有关,而与对象本身无关——某个对象只有它的引用关系能决定它存活与否,对象上其他所有的属性都不能够影响它的存活判定结果
3、HotSpot虚拟机的几种收集器有不同的标记实现方案,有的把标记直接记录在对象头上(如Serial收集器),有的把标记记录在与对象相互独立的数据结构上(如G1用称为BitMap的结构来记录标记信息),而ZGC的染色指针直接把标记信息记在引用对象的指针上
4、通过指针上的标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问到
优点:
**染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理;使得理论上只要还有一个空闲Region,ZGC就能完成收集
**染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作,实际上,到目前为止ZGC都并未使用任何写屏障,只使用了读屏障(一部分是染色指针的功劳,一部分是ZGC现在还不支持分代收集,天然就没有跨代引用的问题),所以ZGC对吞吐量的影响也相对较低
**染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能
Java虚拟机作为一个普普通通的进程, 这样随意重新定义内存中某些指针的其中几位,操作系统是否支持?处理器是否支持?需要使用虚拟内存映射技术解决
ZGC使用了多重映射将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大,把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了
流程:
1、并发标记:并发标记是遍历对象图做可达性分析的阶段,前后也要经过初始标记、最终标记的短暂停顿,ZGC 的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的标志位
2、并发预备重分配:这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集,ZGC划分Region的目的并非为了像G1那样做收益优先的增量回收,相反,ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本;因此,ZGC的重分配集只是决定了里面的存活对象会被重新复制到其他的Region中,里面的Region会被释放,而并不能说回收行为就只是针对这个集合里面的Region进行,因为标记过程是针对全堆的
3、并发重分配:重分配是ZGC执行过程中的核心阶段
--这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表,记录从旧对象到新对象的转向关系
--得益于染色指针的支持,ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”能力
--这样做的好处是只有第一次访问旧对象会陷入转发,也就是只慢一次
--由于染色指针的存在,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配(但是转发表还得留着不能释放掉),哪怕堆中还有很多指向这个对象的未更新指针也没有关系,这些旧指针一旦被使用,它们都是可以自愈的
4、并发重映射:重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用
--并不是一个必须要“迫切”去完成的任务,因为即使是旧引用,它也是可以自愈的,最多只是第一次使用时多一次转发和修正操作,重映射清理这些旧引用的主要目的是为了不变慢(还有清理结束后可以释放转发表这样的附带收益),所以说这并不是很“迫切”
--ZGC把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销,一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了
优缺点:
1、ZGC完全没有使用记忆集,它甚至连分代都没有,连像CMS中那样只记录新生代和老年代间引用的卡表也不需要,因而完全没有用到写屏障,所以给用户线程带来的运行负担也要小得多
2、ZGC的这种选择也限制了它能承受的对象分配速率不会太高,ZGC准备要对一个很大的堆做一次完整的并发收集,假设其全过程要持续十分钟以上,在这段时间里面
由于应用的对象分配速率很高,将创造大量的新对象,这些新对象很难进入当次收集的标记范 围,通常就只能全部当作存活对象来看待——尽管其中绝大部分对象都是朝生夕灭的,这就产生了大量的浮动垃圾,如果这种高速分配持续维持的话,每一次完整的并发收集周期都会很长,回收到的内存空间持续小于期间并发产生的浮动垃圾所占的空间,堆中剩余可腾挪的空间就越来越小了
3、目前唯一的办法就是尽可能地增加堆容量大小,获得更多喘息的时间,但是若要从根本上提升ZGC能够应对 的对象分配速率,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集
内存分配与回收策略
对象的内存分配,应该都是在堆上分配(而实际上也有可能经过即时编译后被拆散 为标量类型并间接地在栈上分配)
在经典分代的设计下,新生对象通常会分配在新生代中,少数情况下(如对象大小超过一定阈值)也可能会直接分配在老年代
1、大对象直接进入老年代:在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复制对象时,大对象就意味着高额的内存复制开销
2、长期存活的对象将进入老年代:对象通常在Eden区里诞生,如果经过第一次 Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁,对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中
3、空间分配担保:
--在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的
--如果不成立,会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,就要进行一次Full GC

JVM-逃逸分析

逃逸分析的基本原理
1、分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度
2、如果能证明一个对象不会逃逸到方法或线程之外(即别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化
栈上分配
1、在Java虚拟机中,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据
2、虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源
3、如果确定一个对象不会逃逸出线程之外,就可以将这个对象在栈上分配内存,对象所占用的内存空间就可以随栈帧出栈而销毁
4、实际上完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多
5、栈上分配可以支持方法逃逸,但不能支持线程逃逸
标量替换
1、若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据 就可以被称为标量,相对的,如果一个数据可以继续分解,那它就被称为聚合量,Java 中的对象就是典型的聚合量
2、如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换
3、如果逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替
4、将对象拆分后,除了可以让对象的成员变量在栈上 (栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件
5、标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内
同步消除
线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉
后端编译优化步骤
1、方法内联优化
2、逃逸分析
3、无效代码删除

线程-java内存模型(共享内存模型)

定义
1、在并发编程中,需要处理两个关键问题:线程之间如何通信及同步;而Java线程之间的通信由Java内存模型控制,Java内存模型决定一个线程对共享变量的写入何时对另一个线程可见(但是会存在内存可见性问题,需要通过显式的同步机制去处理线程间的执行顺序(相对)问题)
2、Java内存模型将所有的共享变量都存储在主内存中(虚拟机内存的一部分),每个线程还有自己的工作内存
3、线程的工作内存中保存了被该线程使用的共享变量的主内存副本( 不是整个对象,而是对象中具体的变量),线程对共享变量的所有读写操作都必须在工作内存中进行,而不能直接读写主内存中的数据
4、不同的线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成(线程A把本地内存中更新过的共享变量刷新到主内存中去,线程B到主内存中去读取线程A之前已更新过的共享变量)
顺序一致性内存模型(理论模型)
特性:
1、一个线程中的所有操作必须按照程序的顺序来执行
2、无论程序是否同步,所有线程都只能看到一个单一的操作执行顺序;在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见
JMM特性:
1、未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致
2、当前线程把数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见;因此,当前线程和其他线程看到的操作执行顺序将不一致
3、临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义),JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,虽然线程在临界区内做了重排序,但由于监视器互斥执行的特性,其他线程看不到当前线程在临界区内的重排序
4、JMM不保证单线程内的操作会按程序的顺序执行;JMM不保证所有线程能看到一致的操作执行顺序

原子性、可见性、有序性
Java内存模型需要处理3个问题:原子性、可见性和有序性
原子性:
1、由JMM来直接保证的原子性变量操作包括read、load、assign、use、store和write这六个, 基本数据类型的访问、读写都是具备原子性的(例外就是long和double的非原子性协定)
2、如果应用场景需要一个更大范围的原子性保证(经常会遇到),JMM还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作;这两个字节码指令反映到Java 代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性
可见性:
1、可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改---JMM是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存通信的方式来实现可见性的,无论是普通变量还是volatile变量都是如此
2、普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新;因此我们可以说volatile保证了多线程操作 时变量的可见性,而普通变量则不能保证这一点
3、同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)”这条规则获得的
4、final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那么在其他线程中就能看见final字段的值
有序性:
1、线程内所有的操作都是有序的(串行语义);线程间所有的操作是无序的(指令重排序、工作内存与主内存同步延迟)
2、Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,---volatile关键字本身就包含了禁止指令重排序的语义
3、synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入
先行发生原则-happens-before
定义:在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在先行发生关系,先行发生原则用于描述内存可见性;两个操作既可以是在一个线程之内,也可以是在不同线程之间
两个操作之间具有先行发生关系,并不意味着前一个操作必须要在后一个操作之前执行,先行发生仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前
JMM先行发生(不是时间上的先后)规则:
1、程序顺序规则:一个线程中的每个操作,先行发生于该线程中的任意后续操作
2、监视器锁规则:对一个锁的解锁,先行发生(时间上的先后)于随后对这个锁的加锁
3、volatile变量规则:对一个volatile变量的写,先行发生(时间上的先后)于任意后续对这个volatile变量的 读
4、Thread对象的start方法先行发生于此线程的每一个动作
5、线程中的所有操作都先行发生于对此线程的终止检测
6、对线程interrupt方法的调用先行发生于被中断线程的代码检测到中断事件的发生
7、一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize方法的开始
8、传递性:如果A先行发生于B,且B先行发生于C,那么A先行发生于C

volatile(可见性、有序性)

volatile读:如果主内存变量值有更新,将本地内存中变量值失效,直接从主内存中读取最新值
volatile写:更新本地内存的同时,会立即刷新到主内存(主内存更新时会通知其他线程)

synchronized(可见性、有序性、原子性,互斥可重入,锁是存放在对象头里面的)

同步块:monitorenter和monitorexit指令实现
同步方法:依靠方法修饰符上的ACC_SYNCHRONIZEN同步位来实现
如果线程获取锁失败,线程进入同步队列,线程状态变为BLOCKED
当获取锁的线程释放了锁,会同时唤醒阻塞在同步队列中的线程,使其重新尝试对锁的获取
等待通知机制:
wait方法:前提是已经获取了锁,调用该方法的线程从运行状态变成等待状态(线程进入等待队列)并释放锁,只有等待其他线程的通知或中断,超时才会返回
notify方法:前提是已经获取了锁,将等待的线程从等待队列移到同步队列(等待状态变成阻塞状态),但当前线程可能还未执行完并释放锁,等待的线程不会从wait方法返回(获取锁才会返回)


image.png

锁升级(不能降级):
1、无锁
2、偏向锁:Mark Word中存偏向线程id、对象分代年龄、是否是偏向锁标志、锁标志位;单线程时,避免了CAS加锁/解锁,只需判断偏向线程id是否是自己,如果不是,且现在是偏向锁,则CAS将偏向线程id替换为自己,否则,CAS竞争锁,到全局安全点,暂停偏向线程,释放锁,锁变成无锁状态(偏向线程已经执行完逻辑)或轻量级锁
3、轻量级锁:Mark Word中存放指向持有锁线程的栈中锁记录的指针、锁标志位;CAS替换锁记录指针,失败,则自旋(避免上下文切换)+CAS获取锁,超过一定时间(次数)膨胀为重量级锁;追求响应时间
4、重量级锁:Mark Word中存指向获取锁的线程的栈中锁记录的指针、锁标志位;线程阻塞,不使用自旋,不消耗CPU;追求吞吐量

final(可见性、有序性、原子性)

final写:在构造方法内的final写与被构造对象的引用赋值,禁止重排序
final读:初次读对象引用与随后读对象的final常量,禁止重排序

JUC-AQS

1、AQS支持独占锁(如ReentrantLock、ReadWriteLock的写锁)和共享锁(如CountDownLatch、ReadWriteLock的读锁)
2、state:表示资源数,volatile修饰,需要保证可见性、有序性、利用CAS保证原子性操作
3、队列节点等待状态:初始状态、取消状态(线程超时或被中断时,会进入取消状态,取消状态不会再往下执行)、唤醒状态(表示当前节点的后继节点正在等待获取资源,当前节点在release或cancel时需要执行unpark来唤醒后继节点)、条件状态(当前节点为条件队列节点,这个状态在同步队列里不会被用到)、传播状态(针对共享锁,设置在head节点releaseShared(释放共享锁)操作需要被传递到下一个节点,用来保证后继节点可以获取共享资源)
4、nextWaiter属性:连接下一个等待condition的节点,或者在共享模式下作为一个特殊节点保存,用来判断是否为共享模式
5、同步队列:双向链表,队尾插入时会有线程竞争(自旋+CAS插入,需要前驱有效节点唤醒,使用LockSupport.park/unpark阻塞释放线程);队首表示获取资源的线程节点
**acquire方法:调用子类的tryAcquire方法尝试CAS获取资源,成功直接返回;失败则阻塞线程,创建节点,自旋+CAS将节点放入同步队列尾部,当前驱有效节点是头节点时,自旋+CAS尝试获取资源(前驱有效节点不为头节点时,阻塞当前线程,直到被前驱节点唤醒;即使当前线程中间被中断过,也可以自旋+CAS尝试获取资源);如果自旋+CAS尝试获取资源出现异常,要将当前节点状态置为取消
**release方法:调用子类的tryRelease方法尝试自旋+CAS释放资源,唤醒同步队列中的后继节点(中间会移除取消状态的节点),后继节点自旋+CAS尝试获取资源,获取成功,则从acquire方法返回
**acquireShared方法:调用子类的tryAcquireShared方法尝试CAS获取资源,成功直接返回;失败则阻塞线程,创建节点,自旋+CAS将节点放入同步队列尾部,当前驱有效节点是头节点时,自旋+CAS尝试获取资源(前驱有效节点不为头节点时,阻塞当前线程,直到被前驱节点唤醒;即使当前线程中间被中断过,也可以自旋+CAS尝试获取资源),获取资源成功后,将当前节点设置为头节点,如果还有剩余资源,让后续节点也尝试获取资源;如果自旋+CAS尝试获取资源出现异常(超时),要将当前节点状态置为取消
**releaseShared方法:调用子类的tryReleaseShared方法尝试自旋+CAS释放资源,唤醒同步队列中的后继节点(中间会移除取消状态的节点),后继节点自旋+CAS尝试获取资源,获取成功,则从acquireShared方法返回
6、子类需要实现的方法:tryAcquire/tryRelease(独占)、tryAcquireShared/tryReleaseShared(共享)、isHeldExclusively(有用到Condition才需要实现)
7、条件队列:双向链表,只有在使用了Condition(AQS的内部类)才会存在条件队列,在使用Condition的方法之前需要先获取锁
**await方法:调用之前当前线程需要先获取资源;创建节点,自旋+CAS将节点放入条件队列尾部;调用release方法释放当前线程已经持有的资源,并移除同步队列节点,唤醒同步队列中的后继节点,如果释放资源出现异常(超时),要将当前节点状态置为取消;当当前线程没有加入到同步队列时,进行自旋,并阻塞当前线程,直到当前线程被唤醒(从条件队列移到同步队列)或期间被中断,并记录中断状态;在同步队列中自旋+CAS尝试获取资源;如果当前节点的nextWaiter不为空,说明节点在获取锁时由于异常或者被中断而被取消,此时需要移除等待队列中取消状态的节点;如果期间被中断过,抛出中断异常
**signal方法:自旋+CAS将条件队列中节点移除,并创建新节点,添加到同步队列尾部,并唤醒线程,在同步中自旋+CAS尝试获取资源,获取成功从await方法返回

JUC-ReentrantLock

lock是显式的获取锁,拥有锁获取与释放的可操作性、非阻塞获取锁、可中断的获取锁(获取到锁的线程能响应中断:当获取到锁的线程被中断时,锁也会被释放)以及超时获取锁(如果超时未获取锁,会返回)等多种synchronized关键字所不具备的同步特性;不能将获取lock锁的过程写在try块中,因为如果在获取锁时发生了异常(已经获取锁),异常抛出时,锁也会被释放;在finally块中释放锁
ReentrantLock是一个可重入的互斥锁,也被称为“独占锁”,“独占锁”在同一个时间点只能被一个线程持有,而“可重入锁”可以被单个线程多次获取;ReentrantLock又分为“公平锁”和“非公平锁”(默认),它们的区别体现在获取锁的机制上:在“公平锁”的机制下,线程依次排队获取锁;而“非公平锁”机制下,如果锁是可获取状态,不管自己是不是在队列的head节点都会去尝试获取锁
内部有一个Sync类继承自AQS,有两个子类FairSync和NonfairSync
1、lock方法:
非公平:调用NonfairSync中的lock方法;尝试CAS获取资源,成功,设置当前线程持有资源;失败,调用AQS的acquire方法获取资源;调用NonfairSync中的tryAcquire方法尝试CAS获取资源(不会判断当前节点前是否还有节点,与公平锁区别):如果没有线程获取资源,CAS尝试获取资源,设置当前线程持有资源,如果当前线程已经获取过资源(可重入),在原来基础上+1(不需要CAS,因为无竞争),成功直接返回;失败则阻塞线程,创建节点,自旋+CAS将节点放入同步队列尾部,当前驱有效节点是头节点时,自旋+CAS尝试获取资源(前驱有效节点不为头节点时,阻塞当前线程,直到被前驱节点唤醒;即使当前线程中间被中断过,也可以自旋+CAS尝试获取资源);如果自旋+CAS尝试获取资源出现异常,要将当前节点状态置为取消
公平:调用FairSync中的lock方法;调用AQS的acquire方法获取资源;调用FairSync中的tryAcquire方法尝试CAS获取资源(要判断当前节点前是否还有节点,与非公平锁区别):如果没有线程获取资源,CAS尝试获取资源,设置当前线程持有资源,如果当前线程已经获取过资源(可重入),在原来基础上+1(不需要CAS,因为无竞争),成功直接返回;失败则阻塞线程,创建节点,自旋+CAS将节点放入同步队列尾部,当前驱有效节点是头节点时,自旋+CAS尝试获取资源(前驱有效节点不为头节点时,阻塞当前线程,直到被前驱节点唤醒;即使当前线程中间被中断过,也可以自旋+CAS尝试获取资源);如果自旋+CAS尝试获取资源出现异常,要将当前节点状态置为取消
2、unLock方法:公平和非公平一致;调用AQS的release方法释放资源;调用Sync的tryRelease方法尝试释放资源:获取资源的次数必须要等于释放资源的次数,这样才算是真正释放了资源,才可以设置持有资源的线程为空,不需要CAS,因为无竞争;唤醒同步队列中的后继节点(中间会移除取消状态的节点),后继节点自旋+CAS尝试获取资源,获取成功,则从acquire方法返回
3、tryLock方法:调用Sync的nonfairTryAcquire方法尝试请求获取资源(不会判断当前节点前是否还有节点,非公平):如果没有线程获取资源,CAS尝试获取资源,设置当前线程持有资源,如果当前线程已经获取过资源(可重入),在原来基础上+1(不需要CAS,因为无竞争),成功直接返回;失败则阻塞线程,创建节点,自旋+CAS将节点放入同步队列尾部,当前驱有效节点是头节点时,自旋+CAS尝试获取资源(前驱有效节点不为头节点时,阻塞当前线程,直到被前驱节点唤醒;即使当前线程中间被中断过,也可以自旋+CAS尝试获取资源);如果自旋+CAS尝试获取资源出现异常,要将当前节点状态置为取消
4、等待通知机制:使用AQS的Condition实现

JUC-CountDownLatch

CountDownLatch是一个同步辅助类,是通过AQS实现的一个可重入的共享锁,可响应中断,会直接抛出中断异常;在其他线程完成操作之前,可以有一个或多个线程等待;内部有一个Sync类,继承自AQS,实现了tryAcquireShared和tryReleaseShared方法
创建CountDownLatch时会指定AQS中state大小
1、await方法:调用AQS的acquireSharedInterruptibly方法获取共享资源;回调子类Sync的tryAcquireShared方法尝试获取资源(当state=0的时候才可以获取资源),成功直接返回;失败则阻塞线程,创建节点,自旋+CAS将节点放入同步队列尾部,当前驱有效节点是头节点时,自旋+CAS尝试获取资源(前驱有效节点不为头节点时,阻塞当前线程,直到被前驱节点唤醒;即使当前线程中间被中断过,也可以自旋+CAS尝试获取资源),获取资源成功后,将当前节点设置为头节点,如果还有剩余资源,让后续节点也尝试获取资源;如果自旋+CAS尝试获取资源出现异常(超时),要将当前节点状态置为取消;如果中间被中断,直接抛出中断异常
2、countDown方法:调用AQS的releaseShared方法释放共享资源;回调子类Sync的tryReleaseShared方法尝试自旋+CAS释放资源,唤醒同步队列中的后继节点(中间会移除取消状态的节点),后继节点自旋+CAS尝试获取资源,获取成功,则从acquireShared方法返回

JUC-ReentrantReadWriteLock

ReentrantReadWriteLock维护了一对相关的锁:共享锁readLock和独占锁writeLock
共享锁readLock用于读操作,能同时被多个线程获取;独占锁writeLock用于写入操作,只能被一个线程持有(支持锁降级:持有写锁的线程可以在写锁未释放之前获得读锁)
Condition只有在写锁中用到,读锁是不支持Condition的
内部有一个Sync类继承自AQS,有两个子类FairSync和NonfairSync:读锁和写锁共用一个状态,高16位标识读计数了,低16位标识写重入次数;内部有一个静态内部类继承自ThreadLocal用于读记录读线程重入次数
内部有两个静态内部类:ReadLock和WriteLock
1、ReadLock:内部有lock和unLock方法,参考CountDownLatch
**lock方法:调用AQS的acquireShared方法获取资源;回调Sync的tryAcquireShared方法尝试CAS获取资源(持有写锁的线程可以继续获取读锁,没有线程获取写锁,可以尝试CAS获取资源),成功,更新当前线程锁重入次数,直接返回;失败,则阻塞线程,创建节点,自旋+CAS将节点放入同步队列尾部,当前驱有效节点是头节点时,自旋+CAS尝试获取资源(前驱有效节点不为头节点时,阻塞当前线程,直到被前驱节点唤醒;即使当前线程中间被中断过,也可以自旋+CAS尝试获取资源),获取资源成功后,将当前节点设置为头节点,如果还有剩余资源,让后续节点也尝试获取资源;如果自旋+CAS尝试获取资源出现异常(超时),要将当前节点状态置为取消;如果中间被中断,直接抛出中断异常
**unLock方法:调用AQS的releaseShared方法释放共享资源;回调子类Sync的tryReleaseShared方法尝试自旋+CAS释放资源,更新线程重入次数;唤醒同步队列中的后继节点(中间会移除取消状态的节点),后继节点自旋+CAS尝试获取资源,获取成功,则从acquireShared方法返回
2、WriteLock:内部有lock和unLock方法,参考ReentrantLock

线程池

一般不手动创建线程,而是使用线程池(降低资源损耗(创建和销毁线程)、提高响应速度、便于管理)
线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式(规避资源耗尽的风险)
原因:
Executors可以创建3种类型的ThreadPoolExecutor:SingleThreadExecutor、FixedThreadPool和CachedThreadPool
1、FixedThreadPool 和 SingleThreadPool:阻塞队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM
2、CachedThreadPool 和 ScheduledThreadPool:最大线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM
线程池调优:CPU密集型-CPU核数+1,IO密集型-CPU*2,使用有界阻塞队列

线程池原理

核心组件
1、corePool:核心线程池的大小
2、maximumPool:最大线程池的大小
3、BlockingQueue:用来暂时保存任务的工作队列(阻塞队列,为什么用阻塞队列)
4、RejectedExecutionHandler:当ThreadPoolExecutor已经关闭或ThreadPoolExecutor已经饱和时(达到了最大线程池大小且工作队列已满),execute()方法将要调用的Handler
5、线程池工厂:可以通过工厂为线程设置名字
拒绝策略:
1、AbortPolicy:默认策略,在需要拒绝任务时抛出RejectedExecutionException
2、CallerRunsPolicy:直接在 execute 方法的调用线程中运行被拒绝的任务,如果线程池已经关闭,任务将被丢弃
3、DiscardPolicy:直接丢弃任务
4、DiscardOldestPolicy:丢弃队列中等待时间最长的任务,并执行当前提交的任务,如果线程池已经关闭,任务将被丢弃
5、也可以自定义拒绝策略
原理:(为什么要这样设计:避免获取全局锁,完成预热之后,几乎都是在执行第的二步)
1、向线程池提交任务:execute方法(没有返回值)和submit方法(有返回值future,通过future的get方法可以获取返回值(阻塞当前线程,直到任务完成,返回结果))
2、如果当前运行的线程数少于corePoolSize,则创建新线程来执行任务,否则向下执行(需要获取全局锁)
3、如果工作队列未满,将任务加入工作队列,否则向下执行
4、如果当前运行的线程数少于maximumPoolSize,则创建新线程来执行任务,否则向下执行(需要获取全局锁)
5、执行拒绝策略
6、关闭线程池:shutdown(推荐)和shutdownNow方法,遍历线程池中的工作线程,逐个调用线程的interrupt方法来中断线程(无法响应中断的任务可能永远无法终止);shutdown中断的是所有没有正在执行任务的线程(还没停止),shutdownNow中断的是所有正在执行或暂停任务的线程(立即停止)
注:线程池创建线程时,会将线程封装成工作线程Worker,Worker在执行完任务后,会循环从工作队列里获取任务进行执行

Future接口与FutureTask实现类

FutureTask既实现了Future接口,又实现了Runnable接口
FutureTask基于AQS实现
get方法:阻塞当前线程(同步队列中)直到获取结果(类似于acquire方法)
run和cancel方法:改变AQS的状态,唤醒阻塞线程

Executors

FixedThreadPool:可重用固定线程数的线程池,适用于为了满足资源管理的需求,而需要限制当前线程数量的应用场景,即负载比较重的服务器
1、FixedThreadPool的corePoolSize和maximumPoolSize都被设置为创建FixedThreadPool时指定的参数nThreads
2、当线程池中的线程数大于corePoolSize时,keepAliveTime为多余的空闲线程等待新任务的最长时间,超过这个时间后多余的线程将被终止;这里把keepAliveTime设置为0L,意味着多余的空闲线程会被立即终止
3、FixedThreadPool使用无界队列LinkedBlockingQueue作为线程池的工作队列(队列的容量为Integer.MAX_VALUE)(不会拒绝任务,可能会堆积大量的请求,从而导致 OOM)
4、当线程池中的线程数达到corePoolSize后,新任务将在无界队列中等待,因此线程池中的线程数不会超过corePoolSize
5、使用无界队列时maximumPoolSize和keepAliveTime将是效参数

SingleThreadExecutor:单个线程(如果内部工作线程由于异常而被终止,则会新建一个线程替代)的线程池,适用于需要保证顺序地执行各个任务,并且在任意时间点,不会有多个线程是活动的应用场景
1、SingleThreadExecutor的corePoolSize和maximumPoolSize被设置为1
2、其他参数与FixedThreadPool相同
3、SingleThreadExecutor使用无界队列LinkedBlockingQueue作为线程池的工作队列(队列的容量为Integer.MAX_VALUE)

CachedThreadPool:大小无界的线程池,适用于执行很多的短期异步任务的小程序,或者是负载较轻的服务器
1、CachedThreadPool的corePoolSize被设置为0,即corePool为空;maximumPoolSize被设置为Integer.MAX_VALUE,即maximumPool是无界的
2、keepAliveTime设置为60L,意味着CachedThreadPool中的空闲线程等待新任务的最长时间为60秒,空闲线程超过60秒后将会被终止
3、CachedThreadPool使用没有容量的SynchronousQueue作为线程池的工作队列,但CachedThreadPool的maximumPool是无界的;这意味着,如果主线程提交任务的速度高于maximumPool中线程处理任务的速度时,CachedThreadPool会不断创建新线程;极端情况下, CachedThreadPool会因为创建过多线程而耗尽CPU和内存资源
5、SynchronousQueue是一个没有容量的阻塞队列,每个插入操作必须等待另一个线程的对应移除操作,反之亦然
6、CachedThreadPool使用SynchronousQueue,把主线程提交的任务传递给空闲线程执行
执行流程:
1、执行SynchronousQueue.offer(Runnable task);如果当前maximumPool中有空闲线程正在执行SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS),那么主线程执行 offer操作与空闲线程执行的poll操作配对成功,主线程把任务交给空闲线程执行,execute()方法执行完成;否则向下执行
2、当初始maximumPool为空,或者maximumPool中当前没有空闲线程时,将没有线程执行SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS);这种情况下,1将失败,此时CachedThreadPool会创建一个新线程执行任务,execute()方法执行完成
3、2中新创建的线程将任务执行完后,会执行SynchronousQueue.poll(keepAliveTime,TimeUnit.NANOSECONDS);这个poll操作会让空闲线程最多在SynchronousQueue中等待60秒钟;如果60秒钟内主线程提交了一个新任务(主线程执行1)),那么这个空闲线程将执行主线程提交的新任务;否则,这个空闲线程将终止;由于空闲60秒的空闲线程会被终止,因此长时间保持空闲的CachedThreadPool不会使用任何资源

你可能感兴趣的:(问题集锦-JVM与多线程)