不断的学习,我们才能不断的前进
一个好的程序员是那种过单行线马路都要往两边看的人

JVM

jvmzhi-shi-tu-pu-gai-shu

JVM内存结构

本小节讲解了虚拟机里面内存是如何划分的,以及堆里面的对象分配、布局和访问的过程。

运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。不同的区域有不同的用途、以及创建和销毁时间。如下图所示,JVM管理的内存包括以下几个方面:
jvmnei-cun-jie-gou

灰色是所有线程共享的数据区,白色是线程隔离的数据区

程序计数器

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。在JVM概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都依赖这个计数器。

由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(多于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响。

Java虚拟机栈

跟程序计数器一样,Java虚拟机栈也是线程私有的,生命周期跟线程一样。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个 栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(8大基本类型boolean、byte、char、short、int、float、long、double)、对象的引用 和returenAddress类型(指向一条字节码指令的地址)。这些数据类型在局部变量表中的存储空间以局部变量槽来表示,64位的double、float类型占两个变量槽,其他数据类型占用一个。局部变量表所需的内存空间在编译期间完成分配。在进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的。

对象的引用,不等同于对象本身,可能是一个指向对象的起始地址的指针,也可能是一个代表对象的句柄或者其他与此对象相关的位置。

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常,HotSpot虚拟机栈的深度是固定的。
Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存将会抛出OutOfMemoryError异常。

栈帧的结构

Java虚拟机以方法作为基本的执行单元栈帧则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素,栈帧中存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。

在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的Code属性中。

在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为当前栈帧,相关联的方法被称为当前方法。其概念模型如下图所示:

zhan-zheng
局部变量表

局部变量表是存储一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量,并通过访问索引对其访问。局部变量表的容量以变量槽为最小单位。局部变量表存储类型为基本数据类型(boolean、byte、char、short、int、float)、reference 和 returnAddress。

JVM使用局部变量表来完成方法调用时的参数传递。

reference类型表示对一个对象实例的引用;returnAddress类型为字节码jsr、jsr_w和ret服务。

操作数栈

操作数栈也称为操作栈,是一个后进先出栈。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。

动态链接的作用是将常量池中指向方法的符号引用转换为调用方法的直接引用。

方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法:

  • 正常调用完成:执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),方法是否有返回值以及返回值的类型根据遇到何种方法返回指令来决定。
  • 异常调用完成:在方法执行过程中遇到了异常,并且这个异常没有在方法体内进行妥善处理。

无论哪一种退出方式,在退出后,都必须返回到最初方法被调用时的位置。在方法执行完成后,其对应的当前栈帧销毁,前一个栈帧重新变为当前栈帧,如果被调用方法带有返回值,将其返回值压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

本地方法栈

本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别是虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈则是为虚拟机使用的本地方法服务

Java堆

Java堆是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。Java堆可以处于物理上不连续的空间,但是在逻辑上它应该被视为连续的。Java堆可以被设计成固定大小,也可以被设计为可扩展的,当Java堆没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

逃逸分析:分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术
逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。

Java堆是垃圾收集器管理的内存区域。从内存回收的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常会出现新生代、老年代、永久代、Eden空间、From Survivor空间、To Surivivor空间等名词。

堆不一定是线程共享的,比如TLAB,即Thread Local Allocation Buffer。这部分Buffer是从堆中划分出来的,但是是本地线程独享的。

TLAB保证对象的内存分配过程中的线程安全性

方法区

方法区是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。用于存储每一个类的结构。

运行时常量池

运行时常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表,用于存放编译期生成的各种字面量与符号引用,这部分将在类加载后存放到方法区的运行时常量池中。

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说除了编译期生成的常量能进入方法区运行时常量池,在运行期间也可以将新的常量放入池中。

jdk1.7 之后字符串常量池被移到堆里面去了。
String::intern()是一个本地方法,作用是如果字符串常量池已经包含一个等于该String对象的字符串,则返回代表池中这个字符串String的对象引用;否则会将此String对象包含的字符串添加到常量池中,并返回这个String对象的引用。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范定义的内存区域,但是这部分内存也被频繁的使用,也会导致OOM异常。在JDK1.4中加入了NIO(New Input/Ouput)类,引入一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。可以避免在Java堆核Native堆来回复制数据

本机直接内存不会收到Java堆大小的限制,但是会受到本机总内存的限制。

虚拟机对象揭秘

对象的创建

在java语言中创建对象仅仅是一个new 关键字(复制、反序列化例外),而在虚拟机中,对象的创建是怎样的流程呢?

  1. 类加载检查:类加载过程:加载、验证、准备、解析、初始化、使用、卸载
  2. 分配内存:指针碰撞、空闲列表
  3. 初始化零值
  4. 设置对象头
  5. 执行初始化init方法

对于加载,验证,准备,初始化,卸载来说,以上的顺序是固定的,但是对于解析却不一定,因为存在子类重写父类的方法,所以是在运行时再将其解析。

当Java虚拟机遇到一个字节码new指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那就先执行类加载过程,如下图所示:。

shuang-qin-wei-pai

在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存的大小在类加载后便可完全确定。为对象分配空间的任务实际上等同于在Java堆中划分出一块确定大小的内存快。有两种分配方式:

  • 指针碰撞:假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的放在另外一边,中间放一个指针作为分界点的指示器,那分配内存只需仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离。
  • 空闲列表:如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错在一起,那么虚拟机就必须维护一个列表,记录那些内存快可用,在分配饿时候从列表中找到足够大的一块空间划分给对象实例。
    选择那种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理的能力决定,当使用带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞使用基于清除算法的收集器时,采用空闲列表来分配内存

还有一个需要考虑的问题是,对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给A分配内存,指针还没修改,对象B又同时使用了原来的指针来分配内存。有两种可选的解决方案:一种是对分配内存空间的动作进行同步处理;另一种是把内存分配的动作按照线程划分在不同的空间之中,即每个线程在Java堆中预先分配内存,称为本地线程分配缓冲(TLAB)。那个线程要分配内存就在那个线程的本地分配缓冲中分配。在有本地缓冲区用完了,分配新的缓冲区时才需要同步锁定

内存分配完后,虚拟机必须把分配到的内存空间都初始化为零值(不包括对象头)。目的是保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

接下来对对象进行设置,主要是对对象头进行设置(类的元数据、对象的哈希码、对象的GC分代年龄、偏向锁标志位、偏向模式)的设置

new指令执行完成后,执行init()方法,按照程序员的意愿对对象进行初始化。

对象的布局

对象在堆内存中的布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。

对象头包括两类信息:第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程、偏向时间戳。另一类数据是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是那个类的实例。

存储内容 标志位 状态
对象哈希码、对象分代年龄 01 未锁定
指向锁记录的指针 00 轻量级锁定
指向重复级锁的指针 10 膨胀(重量级锁定)
空,不需要记录信息 11 GC标志
偏向线程ID、偏向时间戳、对象分代年龄 01 可偏向

实例数据部分是对象真正存储的有效信息。

对齐填充数据不是必然存在的,仅仅起占位符的作用
参考Synchronized 锁升级过程

对象的访问定位

Java程序会通过栈上的reference数据来操作堆上的具体对象。对象的访问方式是由虚拟机实现的,主流的访问方式主要有使用句柄和直接指针两种:

  • 如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息
  • 如果使用直接地址访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销

使用句柄的最大好处就是reference存储的是稳定句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要被修改

使用直接访问地址的最大好处就是速度更快,节省了一次指针定位的时间开销。

虚拟机类加载机制

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。在Java里面类型的加载、连接和初始化过程都是在程序运行期间完成的。

类加载的时机

一个类型从被加载代虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(loading)、验证(Verification)、准备(Preparation)、解析(Resolutioon)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备和解析三个阶段称为连接,如下图所示:

lei-de-sheng-ming-zhou-qi
其中加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,而解析阶段不一定,在某种情况下可以在初始化之后再开始。

对于初始化阶段,有且只有六种情况必须立即对类进行初始化

  • 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有初始化,则必须触发其初始化阶段。
  • 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行初始化,则需要先初始化。
  • 当初始化类时,如果发现其父类还没有进行初始化,则需要先触发其父类初始化
  • 当虚拟机启动时,用户需要指定一个执行的主类(包含main方法的那个类),虚拟机会先初始化这个主类
  • 当使用jdk7新加入的动态语言支持,如果一个java.lang.invoke.MethodHandler实例解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且对应方法句柄的类没有进行初始化,则需要先触发初始化。
  • 当一个接口定义了jdk8新加入的默认方法(default关键字修饰的接口方法),如果有这个接口的实现类发生了初始化,那么这个接口需要在其之前被初始化。

这六种类型的行为称为一个类型的主动引用,其他行为都不会触发初始化,称为被动引用。

通过字类引用父类的静态字段,不会导致子类被初始化。
通过数组定义来引用类不会触发此类的初始化。

类加载的过程

加载
加载阶段,Java虚拟机需要完成以下三件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

验证

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合Java虚拟机规范的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机的安全。验证阶段会完成四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证。

准备

准备阶段是正式为类中定义的变量(静态变量、被static修饰的变量)分配内存并设置类变量初始值的阶段。这阶段进行的内存分配仅仅包括类变量,不包括实例变量,实例变量在对象实例化时随着对象一起分配在Java堆中。

解析

解析阶段Java虚拟机将常量池内的符号引用替换为直接饮用的过程。符号引用是用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。直接饮用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。

初始化

执行类构造器clinit()方法的过程。

clinit()方法与类的构造函数不同,它不需要显示地调用父类构造器,Java虚拟机会保证在子类的clinit()方法执行前,父类的clinit()方法已经执行完毕。

父类的clinit()先执行,意味着父类定义的静态语句要优先于子类的变量赋值操作。

clinit()方法对于类或者接口来说并不是必须的,如果一个类中没有静态语句快,也没有对变量的赋值操作,那么编译器就不会为这个类生成clinit()方法。

类加载器

类加载器虽然只是用于实现类的加载动作,但它在Java程序起的作用远超类加载阶段。对于任意一个类必须由这个类本身和加载它的类加载器一起共同确立其在Java虚拟机中的唯一性

双亲委派机制

双亲委派模型并不是一个具有强制性约束的模型,大多数的Java程序都会使用以下三个系统提供的类加载器进行加载:

  • 启动类加载器(Bootstrap Class Loader):负责加载类库到虚拟机的内存中,加载<JAVA_HOME>\lib目录,或者-Xboostclasspath参数指定的路径的jar包。启动类加载器无法被Java程序直接饮用。
  • 扩展类加载器(Extension Class Loader):这个类加载器在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现,加载<JAVA_HOME>\lib\ext目录,被java.ext.dirs系统变量所指定的路径中所有的类库。开发者可以直接在程序中使用扩展类来加载Class文件。加载jre里面的jar包
  • 应用程序加载(Application Class Loader):这个类加载由sun.misc.Launcher$AppClassLoader来实现,负责加载用户类路径(ClassPath)上所有的类库
  • 自定义类加载:需要继承java.lang.ClassLoader,并重写它的findClass方法,可以加载自己定义的class文件,可以对class文件进行加密解密。

双亲委派机制的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载。

优点
好处就是Java中的类随着类加载器一起具备了一种带有优先级的层次关系,避免类被重复加载。例如类java.lang.Object,无论哪一个类加载器加载这个类,最终都是委派给模型最顶端的启动类加载器加载,因此Object类在程序的各种类加载器环境中都能够确保是同一个类。

双亲委派机制很好的解决了各个类加载器协作时基础类型的一致性问题(越基础的类越由上层的加载器进行加载)。
缺点
Java应用程序中一般都是上层调用下层,核心API总是被作为最底层来提供服务,它们总是基础,那么有没有可能基础调用上层,比如Integer类调用开发人员写的Java类呢,这是有可能的事情,一个典型的例子就是JNDI,JNDI现在已经是Java的标准服务,它的代码由启动类加载器去加载(根加载器),但JNDI的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现部部署在应用程序的classpath下的JNDI接口提供者(SPI, Service Provider Interface)的代码,但启动类加载器不可能“认识”之些代码,该怎么办?为了解决这个困境,Java设计团队只好引入了一个不太优雅的设计:线程上下文件类加载器(Thread Context ClassLoader),也就是当前线程的类加载器
这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。有了线程上下文类加载器,JNDI服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC等.

当高层提供了统一的接口让底层去实现,同时又要在高层加载或者实例化底层的类时,就必须要通过线程上下文类加载器来帮助高层的ClassLoader找到并加载。

破坏双亲委派机制的方法

  • 自定义类加载器,并重写loadClass 方法,改变类加载的原则。(默认的loadClass 方法采用的是双亲委派机制)
  • 使用线程上下文类加载:比如JDBC的实现,其中Driver驱动本身只是一个接口,每个厂商有不同的实现,原生的JDBC类是由启动类类加载加载的,而JDBC中的Driver需要动态加载不同的数据库的Driver实现,而这些是用户自己的实现,需要应用程序类加载加载,所以原生的JDBC类是无法加载的,所以就引入线程上下文件类加载器,使得启动类加载器进行加载的类,由应用程序类加载器去进行加载了。
  • 热部署:每一个模块都有一个自己的类加载器,当更换模块时,就把模块连同类加载一起换掉,实现代码的热替换,所以类加载器不再是双亲委派模型中的树状结构。

类加载的应用场景

依赖冲突:用于解决maven里面的依赖包冲突
热部署、热加载

Tomcat类加载机制

Tomcat可以看成一个容器,可以在在一个Tomcat内部署多个应用,甚至多个应用内使用了某个类似的几个不同版本,但它们之间却互不影响。原因是当一个应用启动的时候,会为其创建对应的WebappClassLoader,每个JasperLoader加载器对应一个jsp。

tomcatlei-jia-zai-qi
commonLoader:Tomcat最基本的类加载器,加载路径中的class可以
被Tomcat容器本身以及各个Webapp访问
catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见
WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见

每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器。
保证了基础类不会被同时加载。
由保证了在同一个 Tomcat 下不同 web 之间的 class 是相互隔离的。

类加载的过程:

  1. 先在本地cache查找该类是否已经加载过,看看 Tomcat 有没有加载过这个类。
  2. 如果Tomcat 没有加载过这个类,则从系统类加载器的cache中查找是否加载过。
  3. 如果没有加载过这个类,尝试用ExtClassLoader类加载器类加载,(重点来了,这里并没有首先使用 AppClassLoader 来加载类)。这个Tomcat 的 WebAPPClassLoader 违背了双亲委派机制,直接使用了 ExtClassLoader来加载类。这里注意 ExtClassLoader 双亲委派依然有效,ExtClassLoader 就会使用 Bootstrap ClassLoader 来对类进行加载,保证了 Jre 里面的核心类不会被重复加载。 比如在 Web 中加载一个 Object 类。WebAppClassLoader → ExtClassLoader → Bootstrap ClassLoader,这个加载链,就保证了 Object 不会被重复加载。
  4. 如果 BoostrapClassLoader,没有加载成功,就会调用自己的 findClass 方法由自己来对类进行加载,findClass 加载类的地址是自己本 web 应用下的 class。
  5. 加载依然失败,才使用 AppClassLoader 继续加载
  6. 都没有加载成功的话,抛出异常

tomcat热加载的原理
热加载是指启动项目后,如果文件、类进行了改变,不需要重启项目,进行局部加载就行,常用于debug模式。

  1. 后台开启一个线程监听class文件是否发生改变
  2. 如果发生改变,就重新加载类(停止并删除Context 容器及其所有子容器,然后再重新启动Context),并不会删除session
  3. 一般用于开发环境

tomcat热部署的原理
热部署在服务器运行时重新部署项目,不必为了一个项目而停止运行服务器而去重新部署;热部署更多的是在生产环境使用;

  1. 由后台线程定时检测 Web 应用的变化
  2. 如果发生变化,它会重新加载整个 Web 应用
  3. 会清空 Session,一般用于生产环境

垃圾回收

Java内存中的程序计数器、虚拟机栈、本地方法栈 三个区域随线程而生,随线程而灭,其中栈帧随着方法的进入和退出而有条不紊的执行出栈和入栈。每一个栈帧中分配多少内存在类结构确定下来时就已知的,当方法结束或者线程结束时,内存就自然跟着回收了。

Java堆和方法区这两个区域则有很多不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有在处于运行期间,我们才能知道程序究竟会创建那些对象,这部分回收是动态的。

判断算法

引用计数法

在对象中添加一个引用计数器,每当一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。引用计数算法虽然占用了一些额外的内存空间进行计数,但它的原理简单,判断效率很高。但是在Java虚拟机里面没有选用引用计数法来管理内存,主要原因是需要考虑很多额外的情况,譬如单纯的引用计数很难解决对象之间相互循环引用的问题

可达性分析算法

基本思路是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始根据引用关系向下搜索,搜索过程所走过的路径称为引用链,如果某个对象到GC Roots间没有任何引用链相连,则证明此对象是不可能被使用的

在Java里面,固定可作为GC Roots的对象包括:

  • 虚拟机栈(栈帧中的本地变量表) 中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量表、临时变量。
  • 方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 方法区中常量引用的对象,譬如字符串常量池里的引用。
  • 本地方法栈中JNI引用的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象。
  • 所有被同步锁持有的对象
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

三色标记法
白色表示从未访问过的结点;
灰色表示本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色。
黑色表示本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。

三色标记法的步骤:

  1. 初始时,所有对象都在 【白色集合】中;
  2. 将GC Roots 直接引用到的对象 挪到 【灰色集合】中;
  3. 从灰色集合中获取对象:
    3.1. 将本对象 引用到的 其他对象 全部挪到 【灰色集合】中;
    3.2. 将本对象 挪到 【黑色集合】里面。
  4. 重复步骤3,直至【灰色集合】为空时结束。
  5. 结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收。

PS: 在CMS 并发标记阶段,可能产生浮动垃圾。只有在下一轮垃圾回收中才能被清除。
三色标记法与读写屏障

引用

Java将引用分为强引用、软引用、弱引用和虚引用:

强引用:是最传统的“引用”定义,是指程序在代码之中普遍存在的引用赋值,即类似Object obje=new Object 这种引用关系,无论任何情况下,只要强引用还在就不会被GC回收。

软引用:用来描述一些还有作用,但非必需的对象。只被软引用关联的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收。这个特性很适合用来实现缓存:比如网页缓存、图片缓存、浏览器的后退按钮等。

弱引用:用来描述那些非必须对象,但是强度比软引用弱一些。被弱引用关联的对象只能生存到下一次垃圾收集发生为止。GC开始工作时,无论当前内存是否足够,都会回收掉被弱引用关联的对象。只要垃圾收集器扫描到了弱引用所引用的对象,就会进行垃圾回收。

虚引用:称为幽灵引用或者幻影引用,是最弱的一种引用关系,为一个对象设置虚引用的唯一目的就是在被GC回收时会收到一个系统通知。不影响对象的生命周期,虚引用主要用来跟踪对象被垃圾回收的活动。

生存还是死亡

在被可达性分析算法判断为不可达的对象,也不是非死不可,要宣布一个对象真正的死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,如果该对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机就会视为没有必要执行。

在GC 准备释放对象所占用的空间之前,首先会调用finalize()方法

如果这个对象被判定为有必要执行finalize()方法,那么该对象就会被放置在一个F-Queue队列之中,并在稍后由一条虚拟机自动建立的、低调度优先级的Finalizer线程区执行它们的finalize()方法。这里的执行是指JVM会去触发这个方法开始运行,但是不保证会等待他结束。这是为了避免某个对象的finalizer()方法执行缓慢,或者进入死循环,导致F-Queue队列中的其他对象永久等待,导致系统崩溃。

finalize()方法是对象逃脱死亡的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次标记,如果对象在finalize()方法中与引用链上的任何一个对象建立关联,那么在第二次标记时就会被移出即将回收的集合。

回收方法区

方法区的垃圾收集主要回收两部分内容:废弃的常量不再使用的类型

如果没有任何对象引用常量池中的常量,而且虚拟机也没有其他地地方引用这个字面量,那么在内存回收时,这个常量就会被系统清理出常量池

判断一个类型是否可以被回收必须满足三个条件

  • 该类所有的实例都已经被回收。
  • 加载该类的类加载器已经被回收。
  • 该类对应的java.lang.class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

类型满足回收条件不一定就被回收,是否对类型进行回收,需要配置-Xnoclassgc参数进行控制。

回收算法

标记-清除算法

分为标记和清除两部分:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。标记过程就是属于垃圾的判断过程。

缺点:

  • 执行效率不稳定,如果堆中有大量需要被回收的对象,这时必须进行大量标记和清除,执行效率随对象的数据增长而降低。
  • 内存空间碎片化问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不触发另外一次垃圾收集动作。

标记-复制算法

简称为复制算法,它将内存按容量划分为大小相等的两块,每次只使用其中一块,将这一块内存用完了,就将还活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉

  • 如果大量的内存存活,这种算法将会产生大量的内存间复制的开销,
  • 但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象。

而且针对的半区进行内存回收,分配内存时不用考虑有空间碎片的复杂情况。缺点就是可用内存缩小为原来的一半

复制算法通常用于新生代的回收,通常把新生代分为一块较大的Eden空间和两块较少的Survivor空间,每次分配时只使用Eden和其中一块Survivor空间。发生垃圾收集时,将Eden和Survivor中任然存活的对象一次性复制到另外一个Survivor空间上,然后直接清理到Eden和用过的Survivor空间。

默认Eden: SurvivorFrom: SurvivorTo = 8 : 1 : 1

标记-整理算法

复制算法在存活率较高时就要进行较多的复制,效率将会降低。针对老年代对象的死亡特征,使用的是整理算法,在标记完成后,让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存

还有一种综合的解决方案,让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次。

Yong : Old = 1:2

分代收集理论

分代收集理论建立在两个假设之上:

  • 弱分代假说:绝大多数对象都是朝生夕灭的。
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

垃圾收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(对象熬过垃圾收集过程的次数)分配到不同区域之间存储。如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾回收,那么把他们集中到一起,每次回收只关注如何保留少数存活而不是标记那些大量将被回收的对象,就能以较低的代价回收大量的空间。

Java堆一般至少划分为新生代(Young Generation)和老年代(Old Generation)两个区域。在新生代中每次垃圾收集都发现大量对象死去,而每次回收后存活的少量对象将会逐步晋升到老年代中存放。需要注意的是对象不是孤立的,对象之间会存在跨代引用。跨代引用相对于同代引用是极少数的,假如新生代的对象引用了老年代,没有必要为了少量的跨代引用去扫描整个老年代,只需要在新生代上建立一个全局的数据结构(称为记忆集),这个结构把老年代划分成若干个小块,标识出老年代哪一块内存会存在跨代引用

新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集,使用标记复制回收算法

老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集,使用标记整理回收算法

整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

基本规则

  • 对象是在新生代Eden区中分配,Eden空间不足时会触发MinorGC;
  • 大对象直接进入老年代,大对象是指需要大量连续内存空间的Java对象;
  • 长期存活的对象进入老年代,对象每熬过一个GC年龄就加一,当增加到一定程度(默认15)就进入老年代;
  • 如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入到老年代;
  • 在发生Minor GC之前,虚拟机必须检查老年代最大可用的连续空间是否大于等于新生代所有对象总空间,如果这个条件成立,那么Minor GC是安全的,如果不成立,就会继续坚持老年代最大可用的连续空间是否大于Minor GC的历次晋升到老年代对象的平均大小,如果大于,将尝试一次Minor GC,如果小于那就进行一次Full GC

注意事项

  • Eden区域设置太小,内存空间则很快被占满,会增加GC的次数,从而降低JVM的性能。
  • Eden区域设置太大,GC过后对象进入To区,因为Survivor区域空间太小无法容纳这些对象,结果大部分幸存对象只在进行一次或很少次的GC后就会被移动到老年代,这就失去了划分年轻代的意义。

记忆集 和卡表

垃圾收集器在新生代中建立了名为记忆集的数据结构,用以避免把整个老年代加载进GC Roots扫描范围,记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
垃圾收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向收集区域的指针就可以了,几种记录精度:

  • 字长精度,每个记录精确到一个机器字长。
  • 对象精度:每个记录精确到一个对象,对象里面有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域含有对象含有跨代指针。

第三种卡精度所指的是用一种称为卡表的方式去实现记忆集,里面的每一个元素都对应着其标识的内存区域中一块特定大小的内存快,也称为卡页。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个对象的字段存在跨代指针,那就将对应的卡表的数组元素标识为1,称为Dirty。当其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏。
虚拟机通过写屏障来维护卡表的状态的,写屏障可以看成在虚拟机层面对引用类型字段赋值这个动作的AOP切面,在赋值的时候会产生一个环形通知,在赋值之前的写屏障称为写前屏障,在赋值操作之后的写屏障称为写后屏障。

CMS 收集器与 G1收集器

CMS 收集器

CMS(Concurrent Mark-Sweep)是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器,基础的算法是标记清除,适应场景是较多的存活时间长的对象(老年代比较大)、服务器响应性能要求高。CMS 垃圾收集器只回收老年代和永久代,CMS可以减少重新标记的停顿时间,使用标记清除算法,一共有四个步骤:

  • 初始标记:标记老年代中的GC Root对象 和 跟GC Root对象相关联的对象。这个阶段会导致STW
  • 并发标记:从 GC Roots的直接关联对象出发开始遍历整个对象图的过程,这个过程比较耗时,但是不需要停顿用户线程,可以并发执行。把这段时间把晋升到老年代的对象,或者更新老年代对象的引用关系的Card标识为Dirty,以供后续扫描这些Dirty Card的对象。
  • 重新标记:为了修正并发标记期间,因用户线程操作导致标记发送变动的那一部分标记记录,这个阶段会产生STW。完整的标记整个老年代存活的对象,标记的范围是整个堆,包括年轻代和老年代,因为年轻代中可以引用了老年代的对象,所以该老年代对象会被视为存活对象,即使新生代对象不可达。所以可以在执行重新标记之前使用 Parallel New或Serial 执行一次YongGC,清除掉年轻代中不可达对象。这个阶段会导致第二次STW。
  • 并发清除:与用户线程同时运行,清除掉标记阶段判断的死亡对象。

缺点

  • 在并发阶段,虽然不会导致用户线程停顿,但是占用了一部分线程,导致总吞吐量下降。
  • 无法处理浮动垃圾,可能会导致当前CMS失败,从而导致一个完全的STW的Full GC产生。浮动垃圾是指并发标记和并发清除阶段,用户线程产生的垃圾对象,但是这部分垃圾对象是出现在标记之后,是不会被当前CMS回收的。
  • CMS 垃圾收集器支持并发执行,所以会预留一部分内存空间给用户线程使用,比如当老年代使用的内存空间达到一个阈值(68%),就会出发CMS 垃圾回收,如果留下的内存空间不够用户线程使用的话,就会导致出现并发失败的错误。
  • CMS 使用的是标记清除,所以存在内存碎片,当分配大对象的时候会导致内存不足,解决方案就是可以开启整理的过程。

G1 收集器

Garbage First (G1) 收集器采用基于 Region 的内存布局形式,设计的目的是替换到CMS,在jdk 9以上的版本中CMS垃圾收集器被声明为不推荐使用,如果强制使用还会受到一个警告信息。
在G1 之前的垃圾回收器,面向的目标范围要么是新生代、老年代、要么就是整个堆,而G1 可以面向堆内存任何部分来组成回收集进行回收,衡量的标准变成那块内存中存放的垃圾数量最多,回收的收益最大

G1 把连续的Java 堆划分为多个大小相等的独立区域(Region),每一个Region 都可以根据需要,扮演新生代的Eden、Survivor空间、和老年代空间。收集器能够对不同角色的Region 采用不同的策略去处理,这样无论是新生的对象、还是存活很久的对象都可以获取很好的收集结果。G1 还有一个特殊的Humongous区域,专门用来存储大对象,只要超过 Region 容量一半的对象就是大对象,每个Region的大小范围是1-32MB,且为2的N次幂。对于超过整个Region容量的超级大对象,会存放在N个连续的Humongous 之中。

G1 保留了新生代和老年代的概念,但他们已经不再是固定的了,G1是根据Region来进行回收的。

G1 会跟踪各个Region 里面垃圾堆积的价值大小,然后后台维护一个优先级列表,每次根据设定的停顿时间,优先处理哪些收益大的Region。

G1 的每个Region 都会维护自己的记忆集,这些记忆集会记录别的Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内,这个卡表的结构是双向的,记录了我指向谁,谁指向我。
G1 收集器采用原始快照SATB 算法来解决并发阶段用户线程改变对象引用关系时,改变了原来对象图结构,从而导致标记结构出现错误
G1 为每一个Region 设计了两个名为TAMS(Top at Mark Start)的指针,把一部分空间划分出来用于高并发回收过程的新对象分配,并发回收时新分配的对象必须在这两个指针位置之上,默认这个地址以上的对象是被隐式标记过的,默认是存活的。
如果内存回收速度赶不上内存分配的速度,则G1收集器就会被冻结,导致Full GC,而产生STW

G1 收集器的大致步骤:

  • 初始标记:仅标记GC Roots能直接关联的对象,并且修改TAMS指针的值,让并发标记过程阶段再运行时,用户线程能够正确的在Region中分配对象。这个时候需要停顿线程,但是时间很短。
  • 并发标记:从Gc Root 开始对堆中对象进行可达性分析,递归扫描整个图,找到回收对象,耗时比较长,但是可以与用户进程并发执行,但是扫描完成后,还需要重新处理SATB记录下的,在并发中引用发送改变的对象。
  • 最终标记:暂停用户线程,用于处理并发阶段结束后仍然遗留下来的少量的SATB记录
  • 筛选回收:对各个Region的回收价值 和成本进行排序,根据用户期望的停顿时间进行制定回收计划,把决定回收的Region的存活对象复制到空的Region 中,再清理掉整个旧Region的全部空间。这里会暂停用户线程。

除了并发标记之外,其他阶段都会暂停用户线程。

Java内存模型与高并发

JMM

Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。此处的变量包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为它们是线程私有的,不会被共享,所以就不存在竞争问题。

Java内存模型规定了所有的变量都存储在主内存(Main Memory)中,每条线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用的变量的主内存副本。线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的关系如下图所示:

xian-cheng-nei-cun-gong-zuo-nei-cun-jiao-hu-guan-xi

内存间交互操作

Java内存模型定义了8种操作来完成主内存和工作内存之间的交互。Java虚拟机实现时必须保存下面这些操作都是原子的、不可再分的:

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存得到的变量值放入工作内存的副本。
  • use(使用):作用于工作内存的变量,它把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接受的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

注意的是 read和load,store和write操作必须按顺序执行,但不要求连续执行。

volatile

java关键字,可以保持可见性、有序性,但是不能保证原子性。可以禁止指令重排优化。

由于volatile变量只能保证可见性,在不满足下面这两种条件下必须要通过加锁来保证原子性:

  • 运算结果不依赖变量的当前值,或者能够确保只有单一线程修改变量的值
  • 变量不需要与其他的状态变量共同参与不变约束。

volatile要求每次使用之前都必须从主内存刷新最新的值,用于保证能看见其他线程队变量V所做的修改;

每次修改后volatile修饰的变量的值时都必须同步回主内存中,保证其他线程可以看到自己对变量V所做的修改;

volatile修饰的变量不会被指令重排许优化,从而保证代码的执行顺序与程序的顺序相同。

具体可看juc下面的JMM

线程

线程的实现方式有三种方式:使用内核线程实现(1:1实现)、使用用户线程实现(1:N实现)、使用用户线程加轻量级进程混合实现(N:M实现)。

Java线程调度

线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式线程调度和抢占式线程调度。

  • 如果使用协同式线程调度,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。
  • 如果使用抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。

线程状态

  • 新建(New):创建后尚未启动的线程处于这种状态。
  • 运行(Runnable):包括操作系统线程状态中的Running和Ready,也就是说处于此状态的线程有可能正在执行,也有可能正在等待操作系统为它分配执行时间。
  • 无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。Object::wait()、Thread::join()、LockSupport::park()方法会让线程进入无限期等待状态。
  • 限期等待(Timed Waiting):处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程唤醒,在一定时间后由系统自动唤醒。Threa::sleep()、Object::wait(time)、Thread::join(time)、LockSupport::parknanos()、LockSupport::parkUntil()方法会让线程进入限期等待状态。
  • 阻塞(Blocked):线程被阻塞了,阻塞状态与等待状态的区别是阻塞状态在等待着获取一个排它锁,这个事件在另外一个线程放弃这个锁的时候发生;而等待状态则是等待一段时间,或者唤醒动作的发生。
  • 结束(Terminated):已终止线程的线程状态。

xian-cheng-zhuang-tai

sleep与wait

  • sleep方法没有释放锁,而wait方法释放了锁使得其他线程可以使用同步控制块或者方法。
  • wait,notify和notifyAll只能在同步控制方法或者同步控制块里面使用,而sleep可以在任何地方使用。
  • sleep必须捕获异常,而wait,notify和notifyAll不需要捕获异常。
  • sleep是线程类(Thread)的方法,导致此线程暂停执行指定时间,给执行机会给其他线程,但是监控状态依然保持,到时后会自动恢复。调用sleep不会释放对象锁。
  • wait是Object类的方法,对此对象调用wait方法导致本线程放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象发出notify方法(或notifyAll)后本线程才进入对象锁定池准备获得对象锁进入运行状态。

线程安全与锁优化

线程安全

线程安全的定义:当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的

可以将Java语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容、线程对立。

不可变
不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全的保障措施。
Java语言中不可变的类型有:java.lang.String、java.lang.Number、枚举类型是不可变的。但是AtomicInteger和AtomicLong则是可变的。

绝对线程安全
绝对的线程安全能够满足线程安全的定义。在Java 中标注是线程安全的类,大多数都不是绝对线程安全的,例如java.util.Vector是一个线程安全的容器,因为它的add、get、size方法都被synchronized修饰的。但是并不意味着调用它的时候就不需要进行同步操作:

// 不安全,有java.lang.ArrayIndexOutOfBoundsException:异常
class unsafe{
	private static Vector<Integer> vectors=new Vector<Integer>();
	public static void main(String[] args) {
		while (true){
			for(int i=0;i<10;i++){
				vectors.add(i);
			}
			new Thread(() -> {
				for (int i = 0; i < vectors.size(); i++) {
					vectors.remove(i);
				}
			}, "a").start();
			new Thread(() -> {
				for (int i = 0; i < vectors.size(); i++) {
					vectors.get(i);
				}
			}, "a").start();
			while (Thread.activeCount()>20);
		}
	}
}
// 添加锁后,保证vector访问的线程安全
class safe{
	private static Vector<Integer> vectors=new Vector<Integer>();
	public static void main(String[] args) {
		while (true){
			for(int i=0;i<10;i++){
				vectors.add(i);
			}
			new Thread(() -> {
				synchronized (vectors){
					for (int i = 0; i < vectors.size(); i++) {
						vectors.remove(i);
					}
				}
			}, "a").start();
			new Thread(() -> {
				synchronized (vectors){
					for (int i = 0; i < vectors.size(); i++) {
						vectors.get(i);
					}
				}
			}, "a").start();
			while (Thread.activeCount()>20);
		}
	}
}

虽然这里get、remove、size方法都是同步的,但是如果不在方法调用端做额外的同步措施,使用这段代码仍然是不安全的,因为如果另外一个线程恰好在错误的时间里删除了一个元素,导致序号i已经不可再用了,另外一个线程在使用i就会抛出ArrayIndexOutOfBoundsException异常。

相对线程安全

相对线程安全就是通常意义上讲的线程安全,它只需要保证对象的单次操作是线程安全的。Java里面的Vector、HashTable、Collection.synchronizedCollection()属于相对线程安全。

线程兼容

线程兼容表示对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证在并发环境中可以安全的使用。

线程对立

线程对立是指不管调用端是否采取同步措施,都无法在多线程环境中并发使用代码。

线程安全的实现方法

互斥同步

互斥同步是一种最常见也是最主要的并发正确性保障手段。同步是指多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)、信号量(Semaphore)都是互斥实现方式。

在Java里面最常见的互斥同步手段就是synchronized关键字,该关键字经过javac编译之后,会在同步快的前后分别形成monitorenter和monitorexit这两个字节码指令。在执行monitorenter指令时,首先去尝试获取对象的锁,如果这个对象没有被锁定,或者当前对象已经持有了那个对象的锁,就把锁的计数器值加一,在执行monitorexit指令时会将锁的计数器值减一。一旦计数器的值为零,锁就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。 synchronized锁需要注意:

  • 被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现把自己锁死的情况。
  • 被synchronized修饰的同步块在持有锁的线程执行完毕并释放之前,会无条件地阻塞后面其他线程的进入。

JUC下的可重入锁(ReentrantLock)是Lock接口的最常用的一种实现。ReentrantLock相比synchronized增加了一些高级功能,主要包括:

  • 等待可中断:指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 公平锁:多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。
  • 锁绑定多个条件:是指一个ReentrantLock对象可以同时绑定多个Condition对象。在synchronized中,锁对象的wait()跟它的notify()或者notifyAll方法配合可以实现一个隐含的条件,如果要和多于一个的条件关联时,就不得不额外添加一个锁;而 ReentrantLock无须这样做,多次调用newCondition()方法即可。

非阻塞同步

互斥同步面临的主要问题是进行线程阻塞和唤醒带来的性能开销,因此也称为阻塞同步。互斥同步属于一种悲观的并发策略。非阻塞同步就是指:基于冲突检测的乐观并发策略,也就是说先进行操作,如果没有发生其他线程争用共享数据,那么操作就成功了;否则的话再进行补偿措施,通常是不断的重试,直到没有出现竞争。

无同步方案

要保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步与线程安全两者没有必然的联系。同步只是保证存在共享数据争用时正确性的手段。有一些代码天生就是线程安全的:

  • 可重入代码(Reentrant Code):也称为纯代码,是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而在控制权返回后,原来的程序不会出现任何错误。
  • 线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一线程中执行。如果能保证就可以吧共享数据的可见范围保证在同一个线程之内,这样就无须同步了。

锁优化

自旋锁与自适应自旋

互斥同步的时候阻塞是对性能造成最大的影响,在后面的请求锁的那个线程“稍等一会”,但不放弃处理器执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋)。

自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好;反之锁被占用的时间很长,自旋操作只会白白浪费处理器资源。自旋的默认次数是十次。

自适应的自旋:自适应意味着自旋的时间不是固定的了,而是由前一次再同一个锁上的自旋时间以及锁的拥有者的状态来决定。如果锁在同一个对象上,自旋等待刚刚也成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也会成功;如果某个锁,自旋很少成功获得过,那么以后获取这个锁时有可能直接省略掉自旋过程。

锁消除

锁消除是指虚拟机即使编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。

锁粗化

原则上总是推荐同步快的作用范围限制得尽量小--只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少。

但是如果一系列的连续操作都对同一个对象反复加锁和解锁,这样即使没有线程竞争也会导致不必要的性能损耗。StringBuffer的append()方法就属于这类情况,所以虚拟机就会把这种情况的锁同步范围扩展(粗化)到整个操作序列的外部。

轻量级锁

轻量级锁是相对于使用系统互斥量来实现的传统锁而言的。其目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

偏向锁

偏向锁目的是相处数据在无竞争情况下的同步语句,进一步提高程序的运行性能。如果说轻量级锁是在无竞争情况下使用CAS操作去消除同步使用的互斥量,那么偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS都不做了。
偏向锁就是这个锁会偏向于第一个获得它的线程,如果接下来该锁一直没有被其他的线程获取,则持有偏向锁的线程永远不需要同步。一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式马上宣告结束。

死锁

所谓死锁是指多个进程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进。死锁产生的4个必要条件:

  • 互斥条件:进程要求对所分配的资源(如打印机)进行排他性控制,即在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。
  • 不剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。
  • 请求和保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。
  • 循环等待条件:存在一种进程资源的循环等待链,链中每一个进程已获得的资源同时被 链中下一个进程所请求。

JVM优化

JVM 内存参数

jvmnei-cun-can-shu

  • -Xmx:设置堆的最大空间大小
  • -Xms:设置堆的最小空间大小(年轻代+老年代的大小)
  • -Xmn:年轻代大小
  • -XX:NewSize 设置新生代最小空间大小。
  • -XX:MaxNewSize 设置新生代最大空间大小。
  • -XX:PermSize 设置永久代最小空间大小。
  • -XX:MaxPermSize 设置永久代最大空间大小。
  • -Xss 设置每个线程的堆栈大小(栈的最大递归深度)
  • -XXSurvivorRatio 设置Eden 跟 Survivor区域的比值大小
  • –XX:NewRatio 设置年轻代跟老年代的比值大小
  • -XX:+PrintGC 每次进行GC时,打印相关日志
  • -XX:+UseSerialGC 串行回收
  • -XX:+PrintGCDetails 打印更详细的GC日志
  • -XX:+HeapDumpOnOutOfMemoryError 当JVM发送OOM时,自动生成dump文件

可以直接将初始的堆大小与最大堆大小相等,这样的好处是可以减少程序运行时垃圾回收次数,从而提高效率。

Demo

-Xmx10240m -Xms10240m -Xmn5120m -XXSurvivorRatio=8
设置JVM最大可用内存为10240M=10G,初始化堆大小跟最大堆相同;设置年轻代大小为5120m=5G;所以老年代大小为 10G-5G=5G;Eden:From:To = 8:1:1,所以Eden=5G * 8 /10;

调优工具

jps

虚拟机进程状态工具,功能跟linux的ps类似,可以列出正在运行的虚拟机进程,并显示虚拟机执行主类名称,以及这些进程的本地虚拟机唯一ID。

jstat

虚拟机统计信息监视工具,用于监视虚拟机各种运行状态信息的命令行工具,可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即使编译等运行时数据。

jinfo

Java配置信息工具,作用是实时查看和调整虚拟机各项参数。

jmap

Java内存映像工具,用于生成堆转储快照。

jhat

虚拟机堆转储快照分析工具。

jstack

Java堆栈跟踪工具,用于生成虚拟机当前时刻的线程快照。

调优要求

  • 每次GC的时间足够的小
  • GC的次数足够的少 (与第一个矛盾,每次GC的时间要少的话,堆的大小就应该尽可能的小;GC的次数足够的少的要,就要求堆的大小要足够的大)
  • Full GC尽量少的原则,让年老代尽量缓存常用对象,JVM的默认比例1:2也是这个道理

Reference

深入理解Java虚拟机

双亲委派机制


目录