《深入理解JVM》读书笔记

第二章 java内存区域与内存溢出异常

2.2 运行时数据区域

程序计数器(Program Counter Register):线程私有。是一块较小的内存空间,是当前线程所执行的字节码的行号指示器。 Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的。每条线程都需要有一个独立的程序计数器。

Java虚拟机栈(VM Stack):线程私有。每个方法在执行都会创建一个栈帧(Stack Frame)用于储存局部变量表、操作数栈、动态链接、方法出口等。 局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型)、returnAddress类型(指向了一条字节码指令的地址)。64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型占用1个。

本地方法栈(Native Method Stack):线程私有。(HashCode算法,线程实现)。

Java堆(Java Heap):线程共享。是Java虚拟机所管理的内存中最大的一块,虚拟机启动时创建。所有的对象实例以及数组都要在堆上分配。 堆是垃圾收集器管理的主要区域。堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。

方法区(Method Area):线程共享。存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等。 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外。还有常量池(Constant Pool Table),存放编译期生成的字面量和符号引用。

2.3 Hopspot

2.3.1 对象创建

虚拟机遇到new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,必须先进行类加载。

指针碰撞 空闲列表

2.3.2 对象的内存布局

对象在内存中储存的布局可以分为:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。 对象头:包括两部分。第一部分用于储存对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳。另一部分是类型指针,通过这个指针确定这个对象是哪个类的实例。 如果对象是一个Java数组,在对象头还有一块用于记录数组长度。 实例数据:对象储存的有效信息,代码中所定义的各种类型的字段内容。 对齐填充:对象的大小必须是8字节的整数倍。对象头已经是8字节的倍数(1倍或2倍)。

2.3.3 对象的访问定位

通过栈上的reference数据来操作堆上的具体对象。 句柄访问:好处是reference中存储的是稳定的句柄地址,在对象被移动(垃圾回收时)时只会改变句柄中的实例数据指针。 直接指针访问:速度更快,节省了一次指针定位的开销。

2.4 实战: OutOfMemoryError异常

2.4.1 Java堆溢出。OutOfMemoryError: Java heap space

不断地创建对象,并且保证GC Roots到对象之间有可达路径。

2.4.2 虚拟机栈和本地方法栈溢出

递归调用方法会得到 StackOverFlowError异常。 内存有限,每个线程分配到的栈容量越大,可以建立的线程数越少,建立线程时越容易把剩下的内存耗尽。

2.4.3 方法区和运行时常量池溢出。

不断创建新的String对象,得到OutOfMemoryError:PermGen Space String.intern()是一个Native方法,如果字符串常量池中已经包含一个等于此String对象的字符串,则返回常量池中的String对象。

第3章 垃圾收集器与内存分配策略

3.1 概述

程序计算器、虚拟机栈、本地方法栈随线程而生,随线程而灭。 每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由JIT编译器进行一些优化)。 Java堆和方法区的内存分配和回收都是动态的。

3.2 对象已死?

3.2.1 引用计数算法

每当一个地方引用它,计数器加一;当引用失效时,计数器减一;计数器为0的对象就是不可能再被使用的。 难题: 互相引用。

1
2
3
4
5
6
7
8
9
10
11
public class ReferenceCountingGC {
  public Object instance = null;
}

ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
System.gc();

3.2.2 可达性分析

GC Roots的对象为起始点,从这些节点向下搜索,搜索所走过的路径为引用链(Reference Chain)。当一个对象到GC Roots没有任何引用链相连,则此对象不可用。 可以作为GC Roots的对象包括: * 虚拟机栈(栈帧中的本地变量表)中的引用对象。 * 方法区中类静态属性引用的对象。 * 方法区中常量引用的对象。 * 本地方法栈中JNI(即Native方法)引用的对象。

3.2.3 再谈引用

https://blog.csdn.net/mazhimazh/article/details/19752475 * 强引用。普遍存在的,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象。

1
Object obj = new Object();
* 软引用。还有用但非必须的对象。如果内存空间不足了,就会回收这些对象的内存。
1
SoftReference<String> softRef=new SoftReference<String>(str);
* 弱引用。被弱引用的对象只能生存到下一次垃圾收集发生之前。不管当前内存空间足够与否,都会回收它的内存。
1
WeakReference<String> abcWeakRef = new WeakReference<String>(str);
* 虚引用。(不知道有啥用)

3.2.5 回收方法区

永久代的垃圾收集主要回收:废弃常量无用的类 废弃常量:一个字符串“acb”已经进入了常量池,没有任何String对象引用常量池中的“abc”常量。 无用的类:需3个条件 * 该类的所有实例都已经被回收。 * 加载该类的ClassLoader已经被回收。 * 该类对应的java.lang.Class对象没有在任何地方被引用,无法通过反射反射访问该类的方法。

3.3 垃圾收集算法

3.3.1 标记-清除算法

首先标记出所有需要回收的对象,随后统一回收所有被标记的对象。 两个不足:效率问题,标记和清除的效率都低。空间问题,会产生大量不连续的内存碎片。

3.3.2 复制算法

将内存按容量划分为大小相等的两块,每次只使用其中的一块。 优点:简单、运行高效。 缺点:内存缩小为原来的一半。

虚拟机都使用该算法回收新生代。一块较大的Eden空间和两块较小的Survivor空间,默认Eden和Survivor的大小比例是8:1。每次使用Eden和其中一块Survivor空间,当回收时,将Eden和Survivor中存活的对象复制到另外一块Survivor。当另外一块Survivor空间不足以存下所有存活对象,则这些对象直接通过分配担保机制进入老年代。

3.3.3 标记-整理算法

复制收集算法在对象存活率较高时要进行较多的复制操作,效率将会变低。老年代一般不使用复制算法。

标记,然后让所有存活的对象都向一端移动,然后清除掉边界以外的内存。

3.3.4 分代收集算法

新生代,少量存活,所以用复制算法。 老年代,存活率高且没有额外空间进行分配担保,所以用标记-清除或标记-整理。

3.4 HotSpot算法实现

3.4.1 枚举根节点

(根节点见3.2.2),必须停顿所有的Java执行线程,Stop the World。 虚拟机应有办法直接得知哪些地方存放对象引用,HotSpot使用一组名为OopMap的数据结构。在JIT编译过程,也会在特定位置(称为安全点)记录下栈和寄存器中哪些位置是引用。

3.4.2 安全点

程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。 抢先式中断:不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点,就恢复线程,让它跑到安全点上。没有虚拟机采用这方法。 主动式中断:不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。

3.4.3 安全区域

线程不执行,处于sleep或者blocked状态,无法响应JVM的中断请求。 安全区域:在一段代码片段中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。

3.5 垃圾收集器

3.5.1 Serial

单线程,必须暂停其他所有的工作线程。采用复制算法。是虚拟机运行在Client模式下的默认新生代收集器。 优点:简单高效,最高的单线程收集效率。 在用户的桌面应用场景,分配给虚拟机管理的内存一般不会很大,停顿时间可以控制在一百多毫秒以内,只要不是频繁发生,可以接受。

3.5.2 ParNew

Serial收集器的多线程版本。使用复制算法。 许多运行在Server模式下的虚拟机中首选的新生代收集器。

3.5.3 Parallel Scavenge

使用复制算法,并行的多线程收集器。新生代。“吞吐量优先”收集器,吞吐量=运行用户代码时间 /(运行用户代码时间+垃圾收集时间) 高吞吐量可以高效利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的。系统把新生代调小一些,收集300MB新生代肯定比收集500MB快,这也导致收集发生得更频繁。原来10秒收集一次,每次停顿100毫秒,现在变成5秒收集一次,每次停顿70毫秒。

3.5.4 Serial Old

老年代,单线程。标记-整理

3.5.5 Parallel Old

老年代,多线程,标记-整理

3.5.6 CMS

老年代。Concurrent Mark Sweep 以获取最短回收停顿时间为目标的收集器。并发低停顿收集器。服务端系统希望停顿时间最短,给用户带来较好的体验。标记-清除,4个步骤: * 初始标记,单线程。Stop the World。仅仅标记GC Roots能直接关联的对象,速度很快。 * 并发标记,多线程。 * 重新标记,多线程。Stop the World。修正并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录。 * 并发清除,多线程。

3个缺点: * 对CPU资源非常敏感,占CPU资源。 * 无法处理浮动垃圾。程序运行会不断有新的垃圾,这部分垃圾出现在标记之后,CMS无法在当次收集中处理掉它们,留待下次GC再清除。并且在垃圾收集阶段用户线程还在运行,需预留足够的内存空间给用户线程,因此CMS收集器不能像其他收集器等到老年代几乎完成被填满了再进行收集。 * 标记-清除,会有大量空间碎片。空间碎片过多,会给大对象分配带来麻烦,老年代还有很大空间剩余,但无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。也可以启动碎片整理。

G1

Garbage-First,面向服务端应用。有4个优点: * 并行和并发, 充分利用多CPU,缩短Stop the World时间。 * 分代收集。 * 空间整合。整体上看是标记-整理。 * 可预测的停顿。明确指定在M毫秒的时间片段内,消耗在垃圾收集的时间不得超过N毫秒。

将整个Java堆划分为多个大小相等的独立区域(Region)。虽然保留新生代和老年代的概念,但不再是物理隔离的。 如何实现可预测的停顿?G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,根据允许的时间,优先回收价值最大的Region。 Region之间的对象引用,虚拟机使用Remembered Set来避免全堆扫描。

G1运作4步骤: * 初始标记,单线程。标记GC Roots能直接关联的对象。 * 并发标记,多线程。从GC Root对堆中对象进行可达性分析。 * 最终标记,多线程。修正在并发标记期间因用户程序继续运作而导致标记长生变动的那一部分标记记录。需停顿线程,但是可并行执行。 * 筛选回收,多线程。Region优先列表(上一段内容)

3.6 内存分配与回收策略

对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB分配。

3.6.1 对象优先在Eden分配

3.6.2 大对象直接进入老年代

需要大量连续内存空间的Java对象,典型例子是很长的字符串以及数组。

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

虚拟机给每个对象定义了一个对象年龄(Age)计数器。每一次Minor GC后年龄都加1。

第4章 工具

4.2.1 jps

列出正在运行的虚拟机进程,并显示虚拟机执行主类(main()函数所在的类)

1
jps -l

1
2
33714 sun.tools.jps.Jps
3877

4.2.2 jstat

1
jstat -gc 3877
1
2
 S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT
 0.0   2048.0  0.0   2048.0 119808.0 62464.0   165888.0   136576.5  182004.0 166672.1 23548.0 19059.4    502    8.253   0      0.000    8.253
  • S0C: Current survivor space 0 capacity (kB).
  • S1C: Current survivor space 1 capacity (kB).
  • S0U: Survivor space 0 utilization (kB).
  • S1U: Survivor space 1 utilization (kB).
  • EC: Current eden space capacity (kB).
  • EU: Eden space utilization (kB).
  • OC: Current old space capacity (kB).
  • OU: Old space utilization (kB).
  • MC: Metaspace capacity (kB).
  • MU: Metacspace utilization (kB).
  • CCSC: Compressed class space capacity (kB).
  • CCSU: Compressed class space used (kB).
  • YGC: Number of young generation garbage collection events.
  • YGCT: Young generation garbage collection time.
  • FGC: Number of full GC events.
  • FGCT: Full garbage collection time.
  • GCT: Total garbage collection time.

4.2.3 jinfo

查看和调整虚拟机各项参数

1
jinfo [ option ] pid

4.2.4 jmap

生成heapdump

4.2.6 jstack 堆栈跟踪工具

生成线程快照threaddump。主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待。

第5章 调优案例分析

5.2.1 高性能硬件上的程序部署策略

深夜执行定时任务的方法触发Full GC。 大多数网站形式的应用,主要对象的生命周期应该是请求级或者页面级的,会话级和全局级的长生命对象相对较少。

64位JDK的问题: * 内存回收导致的长时间停顿 * 性能普遍低于32位 * 堆溢出的文件太大 * 相同程序,64位JDK消耗的内存比32位JDK大,这是由于指针膨胀,数据类型对齐补白。

第二种选择: 使用若干个32位虚拟机建立逻辑集群来利用硬件资源。

5.2.3 堆外内存导致的溢出错误

1
2
java.lang.OutOfMemoryError: null
 at xxxxxxxxx (NativeMethod)

Direct Memory不能发现空间不足就通知垃圾收集器进行垃圾收集,它只能等待老年代满了后Full GC。 大量的NIO操作需要使用到Direct Memory内存。

外部命令导致系统缓慢 fork

某个系统,每个用户请求的处理都需要执行一个外部shell脚本来获得系统的一些消息。 Java虚拟机执行这个命令的过程:首先克隆一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后退出这个进程。

解决方案: 去掉Shell调用。

服务区JVM进程崩溃

某系统使用异步的方式调用OA Web服务,但由于OA太慢,时间越长就积累越多Web服务器没有调用完成,在等待的线程和Socket连接越多。

解决方案: 使用消息队列。

5.2.6 不恰当的数据结构导致内存占用过大

某系统定期导入外部数据文件,在内存生成超过100万个Hash Entry。这时Minor GC就会超过500毫秒的停顿, ParNew收集器使用的是复制算法。

解决方案:GC调优,将Survivor空间去掉,让新生代的对象在第一次Minor GC后立即进入老年代。

5.2.7 由Windows虚拟内存导致的长时间停顿。

当程序最小化的时候,工作内存被自动交换到磁盘的页面文件中。

解决方案: 加入参数 -Dsun.awt.keepWorkingSetOnMinimize=true

第7章 虚拟机类加载机制

后文中堆“类”的描述都包括了类和接口的可能性。 Class文件并非特指某个存在于具体磁盘中的文件,应当是一串二进制的字节流,任何形式都可以。

7.2 类加载的时机

类从加载到虚拟机内存到卸载出内存,生命周期包括:加载、验证、准备、解析、初始化、使用和卸载。

什么时候开始加载? * 使用关键字实例化对象的时候,读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外),调用一个类的静态方法。 * 对类进行反射调用。 * 当初始化一个类时,发现其父类还没有进行初始化,则先触发其父类的初始化。 * 虚拟机启动时,用户指定一个要执行的主类(包含main方法的那个类),虚拟机先初始化这个主类。 * (不懂)

被动引用,TODO

当一个类在初始化时,要求其父类全部都已经初始化过了。但是一个接口在初始化时,并不要求其父类接口全部都完成了初始化。只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。

7.3 类加载的过程

7.3.1 加载

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

通过一个类的全限定名来获取定义此类的二进制字节流: * 从ZIP包中读取 * 从网络中获取 Applet * 运行时计算生成, 如动态代理技术 * 其他文件生成,如JSP文件生成对应的Class

一个数组类创建过程就遵循以下规则: * 如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型。 * 如果数组的组件类型不是引用类型(如int[]),Java虚拟机将会把数组C标记为与引导类加载器关联 * 数组类的可见性与它的组件类型的可见性一样,如果组件类型不是引用类型,那数组类的可见性默认为public

Class对象比较特殊,它虽然是对象,但是存放在方法区里面。

7.3.2 验证

文件格式验证: * 是否以0xCAFEBABE开头 * 主次版本号 * 常量池的常量是否有不被支持的常量类型。 * (其他3个不懂)

元数据验证 * 这个类是否有父类(除了java.lang.Object外,所有的类都应当有父类) * 是否继承了不允许继承的类(被final修饰的类) * 是否实现了父类或接口中要求实现的所有方法 * 类中的字段、方法是否与父类产生矛盾

字节码验证 *(不太懂) * 保证方法中的类型转换是有效的

符号引用验证 * 通过字符串描述的全限定名是否能找到对应的类。 * 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。 * 符号引用中的类、字段、方法的访问性是否可被当前类访问。

7.3.3 准备

为类变量分配内存并设置类变量初始值的阶段,都在方法区进行分配。不包括实例变量,实例变量在对象实例化时随着对象一起分配在Java堆中。并且初始值通常情况下时数据类型的零值

1
public static int value = 123;

变量value在准备阶段后的初始值为0而不是123。把value赋值为123将在初始化阶段才会执行。

如果类字段的字段属性表中存在ConstantValue属性,则直接赋值

1
public static final int value = 123;

7.3.4 解析

虚拟机将常量池中的符号引用替换为直接引用。 符号引用:以一组符号来描述所引用的目标。 直接引用:直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。引用的目标必定已经在内存中。

1.类或接口的解析

假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,过程如下: * 如果C不是一个数组类型,虚拟机将把代表N的全限定名传递给D的类加载器去加载这个类C。 * 如果C是一个数组类型,???

2.字段解析

要解析一个未被解析过的字段符号引用,???,解析字段所属的类或接口的符号引用。解析成功后,类或接口用C表示,对C进行后续字段的搜索: * 如果C本身包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用。查找结束。 * 否则,如果C实现了接口,将会按照继承关系,从下往上递归搜索各个接口和它的父接口。 * 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类。

3.类方法解析

解析出类方法表中class_index项中索引的方法所属的类或接口的符号引用,解析成功后,类或接口用C表示。 * 发现C是接口,抛异常java.lang.IncompatibleClassChangeError * 通过第一步,在C中查找简单名称和描述符都与目标相匹配的方法,返回这个方法的直接引用。 * 否则,在C的父类中递归查找。 * 否则,在C实现的接口类表和它们的父接口中递归查找,如果存在匹配,说明类C是一个抽象类,抛异常java.lang.AbstractMethodError * 否则,查找失败,抛异常java.lang.NoSuchMethodError * 最后,对方法进行权限验证。如果不具备对此方法的访问权限,抛异常java.lang.IllegalAccessError

4.接口方法解析

与类方法解析类似。 接口中的所有方法默认是public,不存在访问权限的问题。

7.3.5 初始化

类加载过程的最后一步。真正执行类中定义的Java代码。 执行类构造器()方法的过程。 * clint方法由编译器自动收集类中的所有类变量的赋值动作和静态句块中(static{})的语句合并生成。 * 虚拟机保证子类的clinit方法执行之前,父类的clinit方法已经执行完毕。 * 父类中的定义的静态语句块要优先于子类的。 * clinit并不是必需的,如果没有静态语句块或对变量的赋值。 * 接口中不能使用静态语句块,但仍然有变量初始化的操作。因此都有clinit * 虚拟机保证线程安全,clinit只执行一次

7.4 类加载器

通过一个类的全限定名类获取描述此类的二进制字节流。

7.4.1 类与类加载器

每一个类加载器,都拥有一个独立的类名称空间。 比较两个类是否相等,只有在这两个类都是由同一个类加载器加载的前提下才有意义。

7.4.2 双亲委派模型

3种系统提供的类加载器: * 启动类加载器。 * 扩展类加载器。 * 应用程序加载器。加载用户类路径Classpath

双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里的父子关系不用继承,而使用组合关系来复用父加载器的代码。 双亲委派模型工作过程:一个类加载器收到了类加载的请求,它首先不会自己尝试加载这个类,而是把这个请求委派给父类加载器。因此所有的加载请求最终都应传送到顶层的启动类加载器中。只有当父加载器反馈无法完成这个加载请求,子加载器才会尝试加载。 好处:Java类随着它的类加载器一起具备了一种具有优先级的层级关系。例如java.lang.Object,它存放在rt.jar中,无论哪一个类加载器要加载这个类,最终都会委派给启动类加载器,因此Object类在程序的各种类加载器环境中都是同一个类。

注意:自己不能创建java.lang.Object,但是能够创建com.wtf.Object

实现双亲委派代码:先检查是否已经加载过,没有则调用父加载器的loadClass()。若失败,调用自己的findClass()

第12章 Java内存模型

12.3 内存模型

屏蔽掉各种硬件和操作系统的内存访问差异,以实现Java程序在各平台下都能达到一致的内存访问效果。

12.3.1 主内存和工作内存

此处的变量包括实例变量、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的。 所有的变量都储存在主内存,每条线程有自己的工作内存。 线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读、写)都必须在工作内存中进行,而不能直接读写主内存中的变量。线程间的变量值的传递需要通过主内存完成。

12.3.3 volatile

轻量级的同步机制。 特点一:保证此变量对所有线程的可见性。当一条线程修改了这个变量的值,新值对于其他线程来说可以立即得知的。普通变量的值在线程间传递需要通过主内存来完成。 由于volatile变量只是保证可见性,在不符合以下两条规则时,仍需要加锁: * 只有单一的线程修改变量值。 * ???

第二个语义:禁止指令重排序优化。P370

1
2
3
boolean initialized = false;
processConfigOptions(xxx);
initialize = true
最后一句的赋值可能被提前执行。

重排后CPU能正确处理指令依赖情况保障程序能得出正确的运行结果(非并发情况)。 volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作要慢一些,因为它需要在本地代码中插入许多内存屏障指令来保障处理器不会发生乱序执行。但总体开销比锁低。

12.3.5 原子性、可见性、有序性

原子性 可见性: volatile保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。还有两个关键字能实现可见性,synchronized和final。 有序性

12.4 Java与线程

12.4.1 线程的实现

线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程可以共享进程资源(内存地址、文件IO等),又可以独立调度(线程是CPU调度的基本单位)。

在Java API中,一个Native方法意味着没有使用平台无关的手段来实现

1.内核线程

内核线程的高级接口–轻量级进程(Light Weight Process)。 一个进程P对应多个轻量级进程LWP,LWP与内核线程一一对应。 系统调用的代价相对较高,需要在用户态和内核态中来回切换。

2.用户线程

完全建立在用户空间的线程库上,系统内核不能感知线程存在。

Java线程的实现

给予操作系统原生线程模型来实现。即1.

12.4.2 Java线程调度

系统为线程分配处理器使用权的过程。Java使用的是抢占式调度。 抢占式调度:每个线程由系统来分配执行时间。 协同式调度:线程的执行时间由线程本身控制。

可以设置线程优先级。1到10,1最低,10最高。

12.4.3 状态转换

任一时间点,一个线程有且只有一种状态: * 新建New,创建后未启动的 * 运行Runable,包括Running和Ready。 可能正在执行,也可能在等待CPU分配执行时间 * 无限期等待Waiting。不会被分配CPU执行时间,需要其他线程显示地唤醒。 没有Timeout的Object.wait(),没有设置Timeout的Thread.join() * 限期等待Timed Waiting * 阻塞Blocked,等待着获取一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生。 * 结束Terminated

第13章 线程安全和锁优化

13.2 线程安全

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。 代码本身封装了所有必要的正确性保障手段(如互斥同步等)。

13.2.1 Java语言中的线程安全

1.不可变

不可变的对象一定是线程安全的。 变量声明为final,在构造函数结束后,它就是不可变的。

2.绝对线程安全

3.相对线程安全 P389

java.util.Vector是一个线程安全的容器,add()\get()\size()方法都被synchronized修饰。

4. 线程兼容 P390

5. 线程对立

无论调用端是否采用了同步措施,都无法在多线程环境中并发使用。

13.2.2 线程安全的实现方法

1.互斥同步 (悲观)

同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个(或一些,使用信号量的时候)线程使用。临界区、互斥量、信号量都是主要的互斥实现方法。 synchronized明确指定了对象参数,那就是这个对象的reference。如果没有明确指定,那根据修饰的是实例方法还是类方法,去取对应的对象实例或Class对象。 Java的线程映射到操作系统的原生线程,如果要阻塞或唤醒一个线程,都需要操作系统完成,从用户态转换为核心态。所以synchronized是java的一个重量级操作。

ReentrantLook,调用lock和unlock,使用try,finally * 等待可中断,正在等待的线程可选择放弃。 * 公平锁,按照申请锁的时间来依次获得锁。默认非公平。 * 绑定多个条件。??? 优先考虑synchronized。

2.非阻塞同步

基于冲突检测的乐观并发策略。先进行操作,如果没有其他线程争用共享数据,那操作就成功了。如果共享数据有争用,就采取补偿措施(如不断尝试)。

需要“硬件指令集”的支持: * Test-and-Set * Fetch-and-Increment * Swap * Compare-and-Swap. ABA问题,准备赋值时是A,期间变成B,最后变回A,CAS操作会误以为它没有改变过。这种情况用互斥锁更好。 * Load-Linked/Store-Conditional

3.无同步方案

不涉及共享数据,那它就无需任何同步。 用到的状态量都由参数中传入。只要输入了相同的数据,就都能返回相同的结果。

4.线程本地存储 Thread Local Storage

把共享数据的可见范围限制在同一个线程内。 经典的Web交互模型中的“一个请求对应一个服务器线程”,使用线程本地存储来解决线程安全问题。

通过java.lang.ThreadLocal类来实现线程本地存储的功能,每一个线程的Thread对象中都有一个ThreadLocalMap对象。

13.3 锁优化

13.3.1 自旋转

互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成。 如果物理机器有1个以上的处理器,能让两个或以上的线程同时并行执行,我们可以让后面请求锁的那个线程“稍等一下”,让线程执行一个忙循环(自旋)。 如果锁被占用的时间很长,那么自旋的线程只是白白消耗处理器资源。

13.3.2 锁消除

虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。

1
2
3
4
5
6
7
public String concatString(String s1, String s2, String s3){
  StringBuffer sb = new StringBuffer();
  sb.append(s1);
  sb.append(s2);
  sb.append(s3);
  return sb.toString();
}
每个StringBuffer.append方法都有一个同步块,锁就是sb对象。sb的引用不会逃逸到concatString方法外,其他线程无法访问到。即时编译后,忽略掉所有的同步。

13.3.3 锁的粗化

我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小。这样是为了需要同步的操作数尽可能变小,如果存在锁竞争,等待锁的线程也能尽快拿到锁。大部分情况下,上面的原则是正确的。 但频繁的互斥同步操作会导致不必要的性能损耗。

以上面的13.3.2代码为例,只需要加锁一次,扩展到第一个append前直到最后一个append后。

13.3.4 轻量级锁,P401有图

轻量级锁并不是用来替代重量级锁,它的本意在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

Hotspot的对象头分为两部分。第一部分用于储存对象自身的运行时数据,如哈希码,GC分代年龄等,被称为Mark Word,这部分是实现轻量级锁和偏向锁的关键。

代码进入同步块的时候,如果同步对象没有锁定,虚拟机先在当前的栈帧中建立一个锁记录(Lock Record)的空间,用于储存锁对象的Mark Word。然后虚拟机使用CAS尝试将对象的Mark Work更新为指向Lock Record的指针。 当两条以上的线程争用同一个锁,轻量级锁将失效,膨胀为重量级锁,Mark Work中将储存指向重量级锁的指针。

“对于绝大部分的锁,在整个同步周期内都是不存在竞争的。“ 如果存在锁竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁比传统的重量级锁更慢。

13.3.5 偏向锁

这个锁会偏向于第一个获得它的线程,如果接下来该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要进行同步。

当锁对象第一次被线程获取的时候,虚拟机将对象头中的标志位设为”01“,即偏向模式。同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word中。持有偏向锁的线程以后进入这个锁相关的同步块时,虚拟机将不再进行同步操作。 当另外一个线程去尝试获取这个锁时,偏向模式结束。

如果程序中大多数的锁总是被多个不同的线程访问,那偏向模式就是多余的。