深入理解Java虚拟机笔记

一、Java内存区域

使用过c/c++我们都知道,我们需要为每一个new的对象去配对delete/free代码,否则容易出现内存泄漏或内存溢出异常。但是Java语言就不需要这么麻烦,因为Java有自动内存管理机制,所以来看一下这中间的奥秘。

1.1运行时数据区域

在这里插入图片描述
1.1.1 程序计数器
可以看作当前线程所执行的字节码指示器。
主要功能:1、字节码解释器通过改变计数器的值来选取需要执行的字节码指令,保证程序正常执行。
2、多线程进行线程切换时,会记录当前线程执行的位置,线程切换回来时,能够恢复到正确的位置。这也是为什么程序计数器为线程私有。 程序计数器为Java虚拟机规范中唯一没有规定OutOfMemoryError情况的区域。、

1.1.2Java虚拟机栈
描述Java方法执行的内存模型:每个方法执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等。每个方法被调用直至完成的过程,就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。

局部变量表:存放了编译器可知的各种基本数据类型,对象引用和returnAddress类型(指向了一条字节码指令的地址)。

这个区域规定了两种异常状况:如果栈的请求深度大于虚拟机所允许的深度,抛出StackOverflowError异常。
如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存时将会抛出OutOfMemoryError异常。

1.1.3本地方法栈
与Java虚拟机栈的作用相似,区别为虚拟机栈为虚拟机执行的Java方法服务,本地方法栈则是为虚拟机使用到的native方法服务。本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。

1.1.4Java堆
Java堆是虚拟机管理的内存中最大的一块,被所有线程共享,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。
注意:但是随着JIT编译器的发展和逃逸分析技术逐渐成熟,所有对象实例在堆中存放也不那么绝对。

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。

在堆中没有内存完成实例分配,堆也无法扩展时,抛OutOfMemoryError异常。

1.1.5方法区
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

方法区也叫‘永久代’,但其实两者并不相等,仅仅是因为HotSpot虚拟机设计团队将GC分代收集扩展至方法区,永久代只是方法区的一种实现。

JDK 8 版本之后方法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取而代之是元空间,元空间使用的是直接内存。

方法区无法满足内存分配需求,抛OutOfMemoryError异常。

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

jdk1.7字符串常量池从方法区拿到堆中,jdk1.8方法区用元空间实现,所以运行时常量池在元空间中。

1.1.7 直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。
主要是源于jdk1.4引入了NIO(异步非阻塞),它的内存分配不受Java堆大小的限制,受本机总内存的大小及处理器的寻址空间的限制。

1.2对象访问

主流的访问方式有两种:使用句柄和直接指针。
如果使用句柄访问方式,Java堆中会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息。
在这里插入图片描述
如果使用直接指针访问方式,Java堆对象的布局中就必须考虑如何放置访问类型的相关信息,reference中直接存储的就是对象地址。
在这里插入图片描述
这两种方式各有优势,使用句柄最大好处就是reference中存放的时稳定的地址,在对象被移动时只会改变实例数据指针,reference本身不需要修改。
直接指针访问方式最大好处就是速度更快,节省了一次指针定位的时间开销。Sun HotSpot就是使用直接指针访问。

二、垃圾收集器与内存分配策略

1、对象是否死亡?

垃圾收集器主要收集的是堆和方法区的内存,因为我们前面介绍的内存中的数据区域,在方法结束或线程结束时,内存自然跟着回收了。但堆和方法区不一样,只有在运行期间才知道会创建哪些对象,这部分内存的分配和回收是动态的。

垃圾回收前需要判断对象是否已死。两种方法:引用计数算法,跟搜索算法。

2.1.1引用计数算法
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1,当引用失效时,计数器值减1。计数器值为0时的对象就是不可能再被使用了。

Java没有采用引用计数算法来管理内存,主要是因为很难解决对象之间相互循环引用的问题。

2.1.2 跟搜索算法
通过一系列名为“GC Roots”的对象作为起点,从这些起点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链时,则证明这个对象不可用。
在Java语言中,可作为GC Roots的对象:
虚拟机栈中的引用的对象
方法区中的类静态属性引用的对象
方法区中的常量引用的对象
本地方法栈中的JNI的引用对象

2.1.3 再谈引用
JDK1.2后,Java引用扩充,为强引用,软引用,弱引用,虚引用。
强引用就是指再代码中普遍存在的,类似Object obj = new Object();这类的引用,只要强引用还存在,垃圾收集器就不会回收掉引用的对象。

软引用用来描述一些还有用,但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之内并进行第二次回收。如果这次回收还是没有足够内存,将会发生内存溢出异常。

弱引用也是用来描述非必需对象的,但是它的强度比软引用更弱一点,被弱引用引用的对象只能生存到下一次垃圾回收之前。

虚引用是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例。为一个对象设置虚引用关联的目的就是希望能在这个对象被垃圾回收器回收时收到一个系统通知。

2.1.4对象非死不可?
在跟搜索算法中不可达对象并非非死不可,要想真正宣告一个对象死亡,至少经历两次标记过程:如果对象在进行跟搜索后发现没有与GC Roots相连的引用链,那么它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况视为没有必要执行。
如果对象有必要执行finalize()方法,虚拟机会将这个对象放入一个F-Queue的队形中,然后调用一条低优先级的Finalize线程去执行。被finalize()方法执行的对象会被第二次标记,对象要想拯救自己,需要重新与引用链上的任一个对象建立关联,否则真的会被回收。

2.1.5回收方法区
有人说方法区不需要回收,Java虚拟机规范也表示不要求,因为在堆中,尤其在新生代,一次垃圾回收可以回收70%~95%的空间,永久代的垃圾收集效率远低于此。
永久代的垃圾收集主要收集两部分内容:废弃的常量和无用的类。
废弃常量判断:没有引用执行常量
无用的类判断:
1、该类所有实例已被回收
2、该类类加载器已被回收
3、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射获取该类的方法。

2、垃圾收集算法

2.1.1标记-清除算法

分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
最基础的收集算法,后续所有的收集算法都是通过此算法改进得来
缺点:效率问题,标记和清除效率不高;空间问题,标记清除后会产生大量不连续内存碎片。

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

缺点:将内存划分为两半,每次使用其中一半,代价未免太高。

现代商业虚拟机都是采用复制算法回收新生代。将内存空间划分为一块较大的Eden区和两块较小的Survivor区。每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活的对象一次性拷贝到另一块Survivor空间,然后清理使用过的Eden区和Survivor区。HotSpot中Eden和Survivor比例8:1。

2.1.3 标记整理算法
复制收集算法需要在对象存活率较高时,执行较多的复制算法,而且还会浪费50%的空间。根据老年代的特点,提出了标记-整理算法。
先对要回收的对象进行标记,然后让存活的对象都向一端移动,然后直接清理掉边界以外的内存。

2.1.4 分代收集算法
当前商业虚拟机都采用分代收集算法,根据对象存货周期将内存分为几块,一般把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法。新生代,每次垃圾回收都有大量对象死去,使用少量存活,采用复制算法;老年代对象存活率高,且没有额外空间担保,采用标记-清除或标记-整理算法。

3、垃圾收集器

垃圾收集器是内存回收的具体实现
3.1.1 Serial收集器(新生代)
Serial收集器是最基本、历史最悠久的收集器,是JDK1.3.1之前虚拟机新生代收集的唯一选择。
是单线程的收集器,进行垃圾收集时,必须暂停其他所有的工作线程,直到收集结束。虚拟机运行在Client模式下的默认新生代收集器。简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程效率。

3.1.2 ParNew收集器(新生代)
是Serial收集器的多线程版本,除了多线程进行垃圾收集,其余行为和Serial收集器可用的所有控制参数都一样。第一个并发收集器,可以让垃圾收集和用户线程同时工作。

3.1.3 Parallel Scavenge收集器(新生代)
新生代收集器,使用复制算法,并行的多线程收集器。CMS等收集器关注点在尽可能缩短垃圾收集时用户线程停顿时间,Parallel Scavenge目的是达到一个可控制的吞吐量。

3.1.4Serial Old收集器(老年代)
是Serial 收集器的老年版本,单线程收集器。使用标记-整理算法,这个收集器主要意义也是被Client模式下的虚拟机使用,Server模式下,两大用途:一个是在JDK1.5及之前版本中与Parallel Scavenge收集器搭配使用,另一个作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Falilure时使用。

3.1.5 Parallel Old收集器(老年代)
是Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法。其出现之前Parall Scavenge收集器只能配合Serial Old收集器工作。注重吞吐量和CPU资源敏感的场合都可以使用Parallel Scavenge加Parallel Old收集器工作。

3.1.6 CMS收集器(老年代)
一种以获取最短停顿时间为目标的收集器。适合互联网站或B/S系统的服务端上,重视响应速度。
采用标记-清除算法。运作过程分为4个步骤:初始标记,并发标记,重新标记,并发清除。

初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;

并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方。

重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短

并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
缺点:对CPU资源敏感
无法处理浮动垃圾
采用标记-清除算法会产生大量空间碎片。

3.1.7 G1 收集器

采用标记-整理算法,可以非常精确地控制停顿。

G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征.

将整个Java堆划分为多个大小固定的独立区域,跟踪这些区域里面的垃圾堆积程度,在后台维护了一个优先列表,每次根据允许的收集时间,优先回收垃圾最多的区域。区域划分及优先级的区域回收,保证了G1收集器在有限的时间内可以获得最高的收集效率。

4、内存分配与回收策略

4.1.1、对象优先在Eden区分配
新生代GC(Minor GC)指发生在新生代的垃圾收集,Minor GC非常频繁,速度也快
老年代GC(Full GC)发生在老年代的GC,出现一次Full GC 会伴随一次Minor GC。Full GC速度比Minor GC慢10倍以上。

大多数情况下,对象优先在Eden区分配,当Eden区没有足够内存时,虚拟机将发起一次Minor GC。

4.1.2大对象直接进入老年代

大对象指需要大量连续内存空间的Java对象,比如很长的字符串及数组。

4.1.3 长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden区出生并经历了一次Minor GC后仍然存活,并且能倍Survivor容纳,将被移动到Survivor空间,将对象年龄设为1,对象在Survivor区没熬过一次Minor GC,年龄增加1岁,当增加到一定程度(默认15岁),就会被晋升到老年代,对象晋升到老年代的年龄阈值,可以通过参数设置。

4.1.4 动态对象年龄判断
如果在Survivor区中相同年龄所有对象大小的总和大于Survivor区的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等待MaxTenuringhreshold中的要求年龄。

4.1.5 空间分配担保
在发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间,如果大于,则改为直接进行一次Full GC,如果小于,则查看HandlePromotionFailure设置是否允许担保失败,如果允许,只会进行Minor GC,否则也要改为进行一次Full GC。

三、虚拟机类加载机制

在这里插入图片描述
3.1.1 加载
完成3件事:
1)通过一个类的全限定名来获取定义此类的二进制流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在Java堆中生成一个代表这个类的Java.lang.Class对象,作为方法区这些数据的访问入口。

3.1.2 验证
连接第一步,主要目的确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
1)文件格式验证:验证字节流是否符合Class文件格式规范,并且能被当前版本的虚拟机处理。
2)元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。
3)字节码验证:通过数据流和控制流分析,保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。
4)符号引用验证:确保解析动作能正确执行。

3.1.3 准备
为类变量分配内存并设置类变量初始值,在方法区分配。

3.1.4解析
虚拟机将常量池内的符号引用替换为直接引用的过程。
符号引用:符号引用是以一组符号来描述所引用的目标,符号可以是任何的字面形式的字面量,只要不会出现冲突能够定位到就行。布局和内存无关,引用的目标不一定已经加载到内存中。
直接引用:是指向目标的指针,偏移量或者能够直接定位的句柄。该引用是和内存中的布局有关的,并且一定是已经加载进来的。

虚拟机的解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行。

3.2 类加载器

3.2.1类加载器
把类加载阶段“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块被称为类加载器。

3.2.2 双亲委派模型
站在Java虚拟机的角度,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;另一种就是所有其他类的类加载器,这些类加载器都由Java语言实现。

站在开发人员的角度,绝大部分Java程序都会使用以下三种系统提供的类加载器:
启动类加载器(BootstrapLoader)将<JAVA_HOME>\lib目录中的,或者是被-Xbootclasspath参数指定的路径中的,并且是虚拟机识别的,类库加载到虚拟机内存中。

扩展类加载器(Extension ClassLoader):负责加载<JAVA_HOME>\lib\ext目录下,或者被java.ext.dirs系统变量所指定路径中的所有类库,开发者可以直接使用扩展类加载器。

应用类加载器(Application ClassLoader):系统类加载器,负责加载用户路径上所指定的类库,如果应用程序没有自己定义类加载器,这个类加载器就是默认类加载器。

所有类加载器都是由这三种类加载器相互配合进行加载的,如果有必要,用户还可以自己定义类加载器。
在这里插入图片描述
图中所展示的类加载器之间的这种层次关系,就称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系是通过组合复用父类加载器的代码。

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

优点:最终所有的Java类都有启动类加载器加载,因此Object类在程序中无论哪个类加载器加载,最终都是同一个类。这种保证了Java程序的稳定运作。

四、Java内存模型与线程

了解过操作系统相关知识就知道,并发多线程的出现就是为了提高系统CPU的资源利用效率,因为计算机的运行速度和它的存储与通讯子系统速度的差距太大了,大部分时间都花在了磁盘I/O,网络通讯和数据库的访问上,单线程的话,让CPU等待其他资源加载的状态,太浪费CPU资源了,所以在处理器和主内存之间又加了高速缓存,但是也引入了缓存一致性的问题,并且Java虚拟机也会出现指令重排的现象,这种会导致数据混乱。

Java内存模型:屏蔽掉各种硬件和操作系统的内存访问差异,实现让Java程序在各种平台上都能达到一致并发的效果。
在这里插入图片描述
4.1.1 主内存和工作内存
主内存:Java内存模型规定了所有的变量都存储在主内存。

工作内存:每条线程都有自己的工作内存,线程的工作内存保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程,主内存,工作内存之间的关系如上图。

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

4.1.3 volatile型变量的特殊规则
关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制。volatile有两种特性:1、保证此变量对所有线程可见性,这里的可见性是指当一条线程修改了这个变量的值,新值对于所有线程来说是立即得知的。

注意:volatile变量对于所有线程来说是一致的,但是并不代表在并发下安全。因为其在Java里面的操作并非原子性的,所有不能保证并发安全。

2、禁止指令重排序优化。
普通的变量只会保证在该方法执行中所依赖赋值结果的地方都能获取到正确的结果,但是不能保证变量赋值操作的顺序于程序代码的执行顺序一致。volatile可以在线程内表现为串行的语义。
这个理解起来比较拗口,但是看了双

4.1.4 原子性、可见性和有序性
原子性:read,load,assign,use,store和write都可以保证原子性。lock和unlock未提供给用户使用,但是提供了更高层次字节码指令monitorenter和monitorexit来隐式使用这两个操作,在synchronized关键字中源码就有使用,所有synchronized块之间也具备原子性。

可见性:一个线程修改了共享变量的某个值,其他线程能够立即得知这个修改。volatile和synchronized和final可保证可见性。

有序性:Java提供了volatile和synchronized保证有序性。

似乎synchronized是万能的。

4.1.5 先行先发原则
如果所有有序性都只靠synchronized和volatile来实现,那将会很麻烦。所以Java语言有先行先发原则。

2、Java与线程

4.2.1 线程的实现
线程是比进程更小的执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程可以共享进程资源,又可以独立调度。Java中java.lang.Thread类就代表一个线程。

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

协同式调度的多线程系统,线程的执行时间由线程本事来控制,线程的工作执行完了之后,要主动通知系统切换到另一个线程上去。协同式多线程的最大好处就是实现简单,而且由于线程要把自己的事情干完之后才会进行线程切换,切换操作对线程自己是可知的,所以没有什么线程同步的问题。
坏处也很明显:线程执行时间不可控制,甚至如果一个线程编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。

抢占式调度的多线程系统,每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(在Java中,Thread.yield()可以让出执行时间,但是要获取执行时间的话,线程本身是没有什么办法的)。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题,Java的线程调度就是抢占式调度。

虽然说Java线程调度是由系统自动完成的,但是我们还是建议系统给线程多分配一点执行时间,另一个线程少分配一点执行时间,这项操作可以通过设置线程优先级来实现。但是线程优先级并不靠谱,因为Java的线程是被映射到系统的原生线程上来实现的,所以线程调度最终还是由操作系统说了算。

4.2.3状态装换

Java语言定义了5种进程状态,在任意一个时间点,一个进程只能有一个状态。
新建(New):创建后尚未启动的线程处于这种状态。

运行(Runable):Runable包括了操作系统线程状态的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待CPU为它分配执行时间。

无限期等待(Waiting):处于这种状态的进程不会被分配CPU执行时间,它们要等待被其他线程显示地唤醒。以下方法将会让线程陷入无限期等待状态。
没有设置Timeout参数地Object.wait()方法。
没有设置Timeout参数地Thread.join()方法。

限期等待(Timed Waiting):处于这种状态的进程也不会被分配CPU执行时间。不过无须等待被其他线程显示地唤醒,在一定时间后它们会由系统自动唤醒。
Thread.sleep()方法。
设置了Timeout参数地Object.wait()方法。
设置了Timeout参数地Thread.join()方法。

阻塞(Blocked):进程被阻塞了,阻塞状态和等待状态区别是:阻塞状态在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁地时候发生;而等待状态则是在等待一段时间,或者唤醒动作地发生。在程序等待进入同步区域的时候,线程将进入这种状态。

**结束(Terminated):**已终止线程的线程状态,线程已经结束执行。

在这里插入图片描述

五、线程安全与锁优化

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

5.1.2 Java语言的线程安全
要讨论Java语言的线程安全,就要限定于多个线程之间共享数据访问的这个前提。Java语言中各种操作共享的数据分为以下5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

1)不可变

在Java语言中,不可变的对象一定是线程安全的。在Java中,被final修饰的变量都是不可变的。比如String类,它的底层是被final修饰了的,所以无论调用substring(),replace()he concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。

2)绝对线程安全

绝对线程安全完全符合Brian Goetz给出的线程安全定义。比如java.util.Vector是一个线程安全的容器。它的add()、get()、size()、方法都被synchronized修饰了,尽管效率低,但确实是线程安全的。但即使它所有的方法都被修饰了同步,也不意味着永远都不用加同步手段。
在多线程的环境下,如果不在方法调用端做额外的同步措施,绝对线程安全的Vector类也是不安全的。

3)相对线程安全

相对线程安全就是通俗意义上的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外同步手段来保证调用的正确性。

4)线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中安全的使用,我们平常说的一个类不是线程安全的,绝大多数都是这种情况。Java API中绝大部分类都是线程兼容的,如与Vector和HashTable对应的集合类ArrayList和HashMap。

5)线程对立

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

一个很好的例子就是Thread类的suspend()和resume()方法,如果有两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,如果并发时,无论调用者是否进行了同步,目标线程都有可能存在死锁的风险。如果suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定要死锁了。

5.1.3线程安全的实现方法
1)互斥同步

互斥同步时最常见的一种并发正确性保障手段,同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条线程使用。

在Java中,可通过synchronized关键字实现互斥同步。synchronized关键字被编译后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。
根据虚拟机规范要求,在执行monitorenter指令时,首先会尝试获取对象的锁。如果这个对象没有被锁定,或者当前线程已经拥有了那个对象的锁,把锁地计数器加1,相应的,在执行monitorexit指令时,会将锁计数器减1.当计数器为0时,锁就被释放了。如果获取对象锁失败了,那当前线程就要阻塞等待,直到对象锁被另一个线程释放为止。

除了synchronized关键字,还可使用java.util.concurren包中的重入锁(ReentrantLock)来实现同步。ReentrantLock 和 synchronized很相似,他们都具备了线程重入的特性,在代码写法上有区别,一个表现为API层面的互斥锁(lock() 和 unlock()方法配合try/finally 语句块完成),一个表现为原生语法层面的互斥锁。ReentrantLock比synchronized增加了一些高级功能,主要有:等待可中断、可实现公平锁、以及锁可以绑定多个条件。

等待可中断是指当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情,可中断特性对处理执行时间非常长的同步块很有帮助。

公平锁是指多个线程在等待同一个琐时,必须按照申请锁的时间顺序来依次获得锁:而非公平锁则不保证这一点,在所释放时,任何一个等待锁的线程都有机会获得锁。

锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象,而在synchronized中,锁对象的wait()和notify()或notifyAll()方法可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外地添加一个锁,而ReentrantLock则无须这样做,只需要多次调用newCondition()方法即可。

至于这两种实现互斥同步地方法性能来说,synchronized关键字更偏向于原生语法层面,虽然早些版本使用synchronized关键字多线程环境下吞吐量下降很严重,但是近些年来synchronized被不断优化,性能也有很大改善,所以深入理解Java虚拟机作者推荐在提倡使用synchronized能实现需求地情况下,尽可能使用synchronized来进行同步。

2)非阻塞同步

互斥同步最主要地问题就是进行多线程阻塞和唤醒所带来地性能问题,因此这种同步也被称为阻塞同步。它是一种悲观地并发策略。随着硬件指令集的发展,我们有了另外一种选择:基于冲突检测的乐观并发策略,通俗地说就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了;如果共享数据有争用,产生了冲突,那就再进行补偿措施(最常见的补偿措施就是不断地重试,直到成功为止)。这种乐观地并发策略不需要把线程挂起,所以被称为非阻塞同步。

CAS指令
比较并交换(Compare-and-Swap)简称CAS。

CAS指令有三个操作数,分别是内存位置(用V表示),旧的预期值(用A表示),新值(用B表示)。CAS指令执行时,当且仅当V符合旧的预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。

3)无同步方案

要保证线程安全,不一定就要进行同步,如果一个方法没有涉及共享数据,那它自然就无需任何同步措施去保证正确性。

可重入代码 :可以在代码执行的任何时候去中断它,转而去执行另外一段代码。而控制权返回后,原来的程序不会出现任何错误。

线程本地存储:如果一段代码中所需要的数据和其他代码共享,如果共享的数据代码能在同一个线程执行,也就不会出现线程安全问题。

5.2锁优化

5.2.1自旋锁和自适应锁
自旋锁是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
缺点:自旋等待本身虽然避免了线程切换的开销,但是如果等待的时间很长的话,自旋的线程就会白白浪费处理器资源。

自适应自旋锁就是线程空循环等待的自旋次数并非是固定的,而是会动态着根据实际情况来改变自旋等待的次数

5.2.2 锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无法进行。

5.2.3 锁粗化
通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是大某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的讲求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。

5.2.4 轻量级锁
如果锁竞争激烈,我们不得不依赖于重量级锁,让竞争失败的线程阻塞;如果完全没有实际的锁竞争,那么申请重量级锁都是浪费的。轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。

顾名思义,轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。

5.2.5 偏向锁
在没有实际竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。偏向锁的目标是,减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。

“偏向”的意思是,偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在Mark Word中CAS记录owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。

偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值