JVM-线程安全与锁优化

1 概述

在软件业发展的初期,程序编写都是以算法为核心的,程序员会把数据和过程分别作为独立的部分来考虑,数据代表问题空间中的客体,程序代码则用于处理这些数据,这种思维方式直接站在计算机的角度去抽象问题和解决问题,被称为面向过程的编程思想。与此相对,面向对象的编程思想则站在现实世界的角度去抽象和解决问题,它把数据和行为都看作对象的一部分,这样可以让程序员能以符合现实世界的思维方式来编写和组织程序。

面向对象的编程思想极大地提升了现代软件开发的效率和软件可以达到的规模,但是现实世界与计算机世界之间不可避免地存在一些差异。例如,人们很难想象现实中的对象在一项工作进行期间,会被不停地中断和切换,对象的属性(数据)可能会在中断期间被修改和变脏,而这些事件在计算机世界中是再普通不过的事情。有时候,良好的设计原则不得不向现实做出一些妥协,我们必须保证程序在计算机中正确无误地运行,然后再考虑如何将代码组织得更好,让程序运行得更快。对于本章的主题“高效并发”来说,首先需要保证并发的正确性,然后在此基础上来实现高效。本章就先从如何保证并发的正确性及如何实现线程安全说起。

2 线程安全

“线程安全”这个名称,相信稍有经验的程序员都听说过,甚至在代码编写和走查的时候可能还会经常挂在嘴边,但是如何找到一个不太拗口的概念来定义线程安全却不是一件容易的事情。笔者尝试在网上搜索它的概念,找到的是类似于“如果一个对象可以安全地被多个线程同时使用,那它就是线程安全的”这样的定义——并不能说它不正确,但是它没有丝毫可操作性,无法从中获取到任何有用的信息。

笔者认为《Java并发编程实战(Java Concurrency In Practice)》的作者Brian Goetz为“线程安全”做出了一个比较恰当的定义:“当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。”

这个定义就很严谨而且有可操作性,它要求线程安全的代码都必须具备一个共同特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无须关心多线程下的调用问题,更无须自己实现任何措施来保证多线程环境下的正确调用。这点听起来简单,但其实并不容易做到,在许多场景中,我们都会将这个定义弱化一些。如果把“调用这个对象的行为”限定为“单次调用”,这个定义的其他描述能够成立的话,那么就已经可以称它是线程安全了。为什么要弱化这个定义?现在先暂且放下这个问题,稍后再详细探讨。

3 Java语言中的线程安全

我们已经有了线程安全的一个可操作的定义,那接下来就讨论一下:在Java语言中,线程安全具体是如何体现的?有哪些操作是线程安全的?我们这里讨论的线程安全,将以多个线程之间存在共享数据访问为前提。因为如果根本不存在多线程,又或者一段代码根本不会与其他线程共享数据,那么从线程安全的角度上看,程序是串行执行还是多线程执行对它来说是没有什么区别的。

为了更深入地理解线程安全,在这里我们可以不把线程安全当作一个非真即假的二元排他选项来看待,而是按照线程安全的“安全程度”由强至弱来排序,我们可以将Java语言中各种操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

不可变
在Java语言里面(特指JDK 5以后,即Java内存模型被修正之后的Java语言),不可变(Immutable)的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再进行任何线程安全保障措施。在第10章里我们讲解“final关键字带来的可见性”时曾经提到过这一点:只要一个不可变的对象被正确地构建出来(即没有发生this引用逃逸的情况),那其外部的可见状态永远都不会改变,永远都不会看到它在多个线程之中处于不一致的状态。“不可变”带来的安全性是最直接、最纯粹的。

Java语言中,如果多线程共享的数据是一个基本数据类型,那么只要在定义时使用final关键字修饰它就可以保证它是不可变的。如果共享数据是一个对象,由于Java语言目前暂时还没有提供值类型的支持,那就需要对象自行保证其行为不会对其状态产生任何影响才行。如果读者没想明白这句话所指的意思,不妨类比java.lang.String类的对象实例,它是一个典型的不可变对象,用户调用它的 substring()、replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。

保证对象行为不影响自己状态的途径有很多种,最简单的一种就是把对象里面带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的,例如代码清单13-1中所示的java.lang.Integer构造函数,它通过将内部状态变量value定义为final来保障状态不变。

代码清单13-1 JDK中Integer类的构造函数

/*** The value of the Integer. * @serial /
private final int value;
/** Constructs a newly allocated Integer object that

represents the specified int value.
** @param value the value to be represented by the
Integer object. */
public Integer(int value) { this.value = value; }

在Java类库API中符合不可变要求的类型,除了上面提到的String之外,常用的还有枚举类型及java.lang.Number的部分子类,如Long和Double等数值包装类型、BigInteger和BigDecimal等大数据类型。但同为Number子类型的原子类AtomicInteger和AtomicLong则是可变的,读者不妨看看这两个原子类的源码,想一想为什么它们要设计成可变的。

4 绝对线程安全

绝对的线程安全能够完全满足Brian Goetz给出的线程安全的定义,这个定义其实是很严格的,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”可能需要付出非常高昂的,甚至不切实际的代价。在Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。我们可以通过Java API中一个不是“绝对线程安全”的“线程安全类型”来看看这个语境里的“绝对”究竟是什么意思。

如果说java.util.Vector是一个线程安全的容器,相信所有的Java程序员对此都不会有异议,因为它的add()、get()和size()等方法都是被synchronized修饰的,尽管这样效率不高,但保证了具备原子性、可见性和有序性。不过,即使它所有的方法都被修饰成synchronized,也不意味着调用它的时候就永远都不再需要同步手段了,请看看代码清单13-2中的测试代码。

代码清单13-2 对Vector线程安全的测试

private static Vector<Integer> vector = new Vector<Integer>(); 
public static void main(String[] args) { 
	while (true) { 
		for (int i = 0; i < 10; i++) { 
			vector.add(i); 
		}
		Thread removeThread = new Thread(new Runnable() { 
			@Override 
			public void run() { 
				for (int i = 0; i < vector.size(); i++) { 
					vector.remove(i); 
				} 
			} 
		}); 
		Thread printThread = new Thread(new Runnable() { 
			@Override 
			public void run() { 
				for (int i = 0; i < vector.size(); i++) { 
					System.out.println((vector.get(i))); 
				} 
			} 
		}); 
		removeThread.start(); 
		printThread.start(); 
		//不要同时产生过多的线程,否则会导致操作系统假死 
		while (Thread.activeCount() > 20); 
	} 
} 

运行结果如下:
Exception in thread “Thread-132” java.lang.ArrayIndexOutOfBoundsException:
Array index out of range: 17
at java.util.Vector.remove(Vector.java:777)
at org.fenixsoft.mulithread.VectorTest$1.run(VectorTest.java:21)
at java.lang.Thread.run(Thread.java:662)

很明显,尽管这里使用到的Vector的get()、remove()和size()方法都是同步的,但是在多线程的环境中,如果不在方法调用端做额外的同步措施,使用这段代码仍然是不安全的。因为如果另一个线程恰好在错误的时间里删除了一个元素,导致序号i已经不再可用,再用i访问数组就会抛出一个ArrayIndexOutOfBoundsException异常。如果要保证这段代码能正确执行下去,我们不得不把removeThread和printThread的定义改成代码清单13-3所示的这样。

代码清单13-3 必须加入同步保证Vector访问的线程安全性

Thread removeThread = new Thread(new Runnable() { 
	@Override 
	public void run() { 
		synchronized (vector) { 
			for (int i = 0; i < vector.size(); i++) { 
				vector.remove(i); 
			} 
		} 
	} 
}); 
Thread printThread = new Thread(new Runnable() { 
	@Override 
	public void run() { 
		synchronized (vector) { 
			for (int i = 0; i < vector.size(); i++) { 
				System.out.println((vector.get(i))); 
			} 
		} 
	} 
});

假如Vector一定要做到绝对的线程安全,那就必须在它内部维护一组一致性的快照访问才行,每次对其中元素进行改动都要产生新的快照,这样要付出的时间和空间成本都是非常大的

5 相对线程安全

相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单次的操作是线程安全的,我们在调用的时候不需要进行额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。代码清单13-2和代码清单13-3就是相对线程安全的案例。

在Java语言中,大部分声称线程安全的类都属于这种类型,例如Vector、HashTable、Collections的 synchronizedCollection()方法包装的集合等。

6.线程兼容

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

7 线程安全的实现方法

了解过什么是线程安全之后,紧接着的一个问题就是我们应该如何实现线程安全。这听起来似乎是一件由代码如何编写来决定的事情,不应该出现在讲解Java虚拟机的书里。确实,如何实现线程安全与代码编写有很大的关系,但虚拟机提供的同步和锁机制也起到了至关重要的作用。在本节中,如何编写代码实现线程安全,以及虚拟机如何实现同步与锁这两方面都会涉及,相对而言更偏重后者一些,只要读者明白了Java虚拟机线程安全措施的原理与运作过程,自己再去思考代码如何编写就不是一件困难的事情了。

7.1、互斥同步

互斥同步(Mutual Exclusion & Synchronization)是一种最常见也是最主要的并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一条(或者是一些, 当使用信号量的时候)线程使用。而互斥是实现同步的一种手段,临界区(Critical Section)、互斥量 (Mutex)和信号量(Semaphore)都是常见的互斥实现方式。因此在“互斥同步”这四个字里面,互斥是因,同步是果;互斥是方法,同步是目的。

在Java里面,最基本的互斥同步手段就是synchronized关键字,这是一种块结构(Block Structured)的同步语法。synchronized关键字经过Javac编译之后,会在同步块的前后分别形成 monitorenter和monitorexit这两个字节码指令。这两个字节码指令都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java源码中的synchronized明确指定了对象参数,那就以这个对象的引用作为reference;如果没有明确指定,那将根据synchronized修饰的方法类型(如实例方法或类方法),来决定是取代码所在的对象实例还是取类型对应的Class对象来作为线程要持有的锁。

根据《Java虚拟机规范》的要求,在执行monitorenter指令时,首先要去尝试获取对象的锁。如果这个对象没被锁定,或者当前线程已经持有了那个对象的锁,就把锁的计数器的值增加一,而在执行 monitorexit指令时会将锁计数器的值减一。一旦计数器的值为零,锁随即就被释放了。如果获取对象锁失败,那当前线程就应当被阻塞等待,直到请求锁定的对象被持有它的线程释放为止。

从功能上看,根据以上《Java虚拟机规范》对monitorenter和monitorexit的行为描述,我们可以得出两个关于synchronized的直接推论,这是使用它时需特别注意的:

被synchronized修饰的同步块对同一条线程来说是可重入的。这意味着同一线程反复进入同步块也不会出现自己把自己锁死的情况。
被synchronized修饰的同步块在持有锁的线程执行完毕并释放锁之前,会无条件地阻塞后面其他线程的进入。这意味着无法像处理某些数据库中的锁那样,强制已获取锁的线程释放锁;也无法强制正在等待锁的线程中断等待或超时退出。

从执行成本的角度看,持有锁是一个重量级(Heavy-Weight)的操作。在第10章中我们知道了在主流Java虚拟机实现中,Java的线程是映射到操作系统的原生内核线程之上的,如果要阻塞或唤醒一条 线程,则需要操作系统来帮忙完成,这就不可避免地陷入用户态到核心态的转换中,进行这种状态转 换需要耗费很多的处理器时间。尤其是对于代码特别简单的同步块(譬如被synchronized修饰的getter()或setter()方法),状态转换消耗的时间甚至会比用户代码本身执行的时间还要长。因此才说,synchronized是Java语言中一个重量级的操作,有经验的程序员都只会在确实必要的情况下才使用这种操作。而虚拟机本身也会进行一些优化,譬如在通知操作系统阻塞线程之前加入一段自旋等待过程,以避免频繁地切入核心态之中。稍后我们会专门介绍Java虚拟机锁优化的措施。

从上面的介绍中我们可以看到synchronized的局限性,除了synchronized关键字以外,自JDK 5起 (实现了JSR 166[1]),Java类库中新提供了java.util.concurrent包(下文称J.U.C包),其中的 java.util.concurrent.locks.Lock接口便成了Java的另一种全新的互斥同步手段。基于Lock接口,用户能够以非块结构(Non-Block Structured)来实现互斥同步,从而摆脱了语言特性的束缚,改为在类库层面去实现同步,这也为日后扩展出不同调度算法、不同特征、不同性能、不同语义的各种锁提供了广阔的空间。

重入锁(ReentrantLock)是Lock接口最常见的一种实现,顾名思义,它与synchronized一样是可重入的。在基本用法上,ReentrantLock也与synchronized很相似,只是代码写法上稍有区别而已。不过,ReentrantLock与synchronized相比增加了一些高级功能,主要有以下三项:等待可中断、可实现公平锁及锁可以绑定多个条件。

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

公平锁:是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁;而非公平锁则不保证这一点,在锁被释放时,任何一个等待锁的线程都有机会获得锁。synchronized中的锁是非公平的,ReentrantLock在默认情况下也是非公平的,但可以通过带布尔值的构造函数要求使用公平锁。不过一旦使用了公平锁,将会导致ReentrantLock的性能急剧下降,会明显影响吞吐量。

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

如果需要使用上述功能,使用ReentrantLock是一个很好的选择,那如果是基于性能考虑呢?synchronized对性能的影响,尤其在JDK 5之前是很显著的,为此在JDK 6中还专门进行过针对性的优化。以synchronized和ReentrantLock的性能对比为例,Brian Goetz对这两种锁在JDK

单核处理器及 双Xeon处理器环境下做了一组吞吐量对比的实验[4],实验结果如图13-1和图13-2所示。

在这里插入图片描述

从图13-1和图13-2中可以看出,多线程环境下synchronized的吞吐量下降得非常严重,而 ReentrantLock则能基本保持在同一个相对稳定的水平上。但与其说ReentrantLock性能好,倒不如说当时的synchronized有非常大的优化余地,后续的技术发展也证明了这一点。当JDK 6中加入了大量针对 synchronized锁的优化措施(下一节我们就会讲解这些优化措施)之后,相同的测试中就发现 synchronized与ReentrantLock的性能基本上能够持平。相信现在阅读本书的读者所开发的程序应该都是使用JDK 6或以上版本来部署的,所以性能已经不再是选择synchronized或者ReentrantLock的决定因素。

根据上面的讨论,ReentrantLock在功能上是synchronized的超集,在性能上又至少不会弱于 synchronized,那synchronized修饰符是否应该被直接抛弃,不再使用了呢?当然不是,基于以下理由,笔者仍然推荐在synchronized与ReentrantLock都可满足需要时优先使用synchronized:

synchronized是在Java语法层面的同步,足够清晰,也足够简单。每个Java程序员都熟悉 synchronized,但J.U.C中的Lock接口则并非如此。因此在只需要基础的同步功能时,更推荐 synchronized。
Lock应该确保在finally块中释放锁,否则一旦受同步保护的代码块中抛出异常,则有可能永远不会释放持有的锁。这一点必须由程序员自己来保证,而使用synchronized的话则可以由Java虚拟机来确保即使出现异常,锁也能被自动释放。

尽管在JDK 5时代ReentrantLock曾经在性能上领先过synchronized,但这已经是十多年之前的胜利了。从长远来看,Java虚拟机更容易针对synchronized来进行优化,因为Java虚拟机可以在线程和对象的元数据中记录synchronized中锁的相关信息,而使用J.U.C中的Lock的话,Java虚拟机是很难得知具体哪些锁对象是由特定线程锁持有的。

7.2、非阻塞同步

互斥同步面临的主要问题是进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步(Blocking Synchronization)。从解决问题的方式上看,互斥同步属于一种悲观的并发策略,其总是认为只要不去做正确的同步措施(例如加锁),那就肯定会出现问题,无论共享的数据是否真的会出现竞争,它都会进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁),这将会导致用户态到核心态转换、维护锁计数器和检查是否有被阻塞的线程需要被唤醒等开销。随着硬件指令集的发展,我们已经有了另外一个选择:基于冲突检测的乐观并发策略,通俗地说就是不管风险,先进行操作,如果没有其他线程争用共享数据,那操作就直接成功了;如果共享的数据的确被争用,产生了冲突,那再进行其他的补偿措施,最常用的补偿措施是不断地重试,直到出现没有竞争的共享数据为止。这种乐观并发策略的实现不再需要把线程阻塞挂起,因此这种同步操作被称为非阻塞同步(Non-Blocking Synchronization),使用这种措施的代码也常被称为无锁(Lock-Free)编程。

为什么笔者说使用乐观并发策略需要“硬件指令集的发展”?因为我们必须要求操作和冲突检测这 两个步骤具备原子性。靠什么来保证原子性?如果这里再使用互斥同步来保证就完全失去意义了,所以我们只能靠硬件来实现这件事情,硬件保证某些从语义上看起来需要多次操作的行为可以只通过一条处理器指令就能完成,这类指令常用的有:

测试并设置(Test-and-Set);
获取并增加(Fetch-and-Increment);
交换(Swap);
比较并交换(Compare-and-Swap,下文称CAS);
加载链接/条件储存(Load-Linked/Store-Conditional,下文称LL/SC)。

其中,前面的三条是20世纪就已经存在于大多数指令集之中的处理器指令,后面的两条是现代处理器新增的,而且这两条指令的目的和功能也是类似的。在IA64、x86指令集中有用cmpxchg指令完成的CAS功能,在SPARC-TSO中也有用casa指令实现的,而在ARM和PowerPC架构下,则需要使用一对 ldrex/strex指令来完成LL/SC的功能。因为Java里最终暴露出来的是CAS操作,所以我们以CAS指令为例进行讲解。

CAS指令需要有三个操作数,分别是内存位置(在Java中可以简单地理解为变量的内存地址,用V 表示)、旧的预期值(用A表示)和准备设置的新值(用B表示)。CAS指令执行时,当且仅当V符合 A时,处理器才会用B更新V的值,否则它就不执行更新。但是,不管是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作,执行期间不会被其他线程中断。

在JDK 5之后,Java类库中才开始使用CAS操作,该操作由sun.misc.Unsafe类里面的 compareAndSwapInt()和compareAndSwapLong()等几个方法包装提供。HotSpot虚拟机在内部对这些方法做了特殊处理,即时编译出来的结果就是一条平台相关的处理器CAS指令,没有方法调用的过程, 或者可以认为是无条件内联进去了[5]。不过由于Unsafe类在设计上就不是提供给用户程序调用的类 (Unsafe::getUnsafe()的代码中限制了只有启动类加载器(Bootstrap ClassLoader)加载的Class才能访问它),因此在JDK 9之前只有Java类库可以使用CAS,譬如J.U.C包里面的整数原子类,其中的 compareAndSet()和getAndIncrement()等方法都使用了Unsafe类的CAS操作来实现。而如果用户程序也有使用CAS操作的需求,那要么就采用反射手段突破Unsafe的访问限制,要么就只能通过Java类库API来间接使用它。直到JDK 9之后,Java类库才在VarHandle类里开放了面向用户程序使用的CAS操作。

下面笔者将用一段在前面章节中没有解决的问题代码来介绍如何通过CAS操作避免阻塞同步。测试的代码如代码清单12-1所示,为了节省版面笔者就不重复贴到这里了。这段代码里我们曾经通过20 个线程自增10000次的操作来证明volatile变量不具备原子性,那么如何才能让它具备原子性呢?之前我们的解决方案是把race++操作或increase()方法用同步块包裹起来,这毫无疑问是一个解决方案,但是如果改成代码清单13-4所示的写法,效率将会提高许多。

代码清单13-4 Atomic的原子自增运算
/*** Atomic变量自增运算测试 ** @author zzm */

public class AtomicTest { 
	public static AtomicInteger race = new AtomicInteger(0);
	public static void increase() { race.incrementAndGet(); }
	private static final int THREADS_COUNT = 20; 
	public static void main(String[] args) throws Exception { 
		Thread[] threads = new Thread[THREADS_COUNT]; 
		for (int i = 0; i < THREADS_COUNT; i++) { 
			threads[i] = new Thread(new Runnable() { 
				@Override public void run() { 
					for (int i = 0; i < 10000; i++) { increase(); } 
				} 
			}); 
			threads[i].start(); 
		}
		while (Thread.activeCount() > 1) Thread.yield(); 
		System.out.println(race); 
	} 
} 

运行结果如下: 200000 使用AtomicInteger代替int后,程序输出了正确的结果,这一切都要归功于incrementAndGet()方法的原子性。它的实现其实非常简单,如代码清单13-5所示。

代码清单13-5 incrementAndGet()方法的JDK源码

/*** Atomically increment by one the current value. 
	* @return the updated value */ 
public final int incrementAndGet() { 
	for (;;) { 
		int current = get(); 
		int next = current + 1; 
		if (compareAndSet(current, next)) 
			return next; 
	} 
} 

incrementAndGet()方法在一个无限循环中,不断尝试将一个比当前值大一的新值赋值给自己。如 果失败了,那说明在执行CAS操作的时候,旧值已经发生改变,于是再次循环进行下一次操作,直到设置成功为止。

尽管CAS看起来很美好,既简单又高效,但显然这种操作无法涵盖互斥同步的所有使用场景,并且CAS从语义上来说并不是真正完美的,它存在一个逻辑漏洞:如果一个变量V初次读取的时候是A 值,并且在准备赋值的时候检查到它仍然为A值,那就能说明它的值没有被其他线程改变过了吗?这是不能的,因为如果在这段期间它的值曾经被改成B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的“ABA问题”。J.U.C包为了解决这个问题,提供了一个带有标记的原子引用类AtomicStampedReference,它可以通过控制变量值的版本来保证CAS的正确性。不过目前来说这个类处于相当鸡肋的位置,大部分情况下ABA问题不会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更为高效。

7.3、无同步方案

要保证线程安全,也并非一定要进行阻塞或非阻塞同步,同步与线程安全两者没有必然的联系。同步只是保障存在共享数据争用时正确性的手段,如果能让一个方法本来就不涉及共享数据,那它自然就不需要任何同步措施去保证其正确性,因此会有一些代码天生就是线程安全的,笔者简单介绍其中的两类。

可重入代码(Reentrant Code):这种代码又称纯代码(Pure Code),是指可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误,也不会对结果有所影响。在特指多线程的上下文语境里(不涉及信号量等因素),我们可以认为可重入代码是线程安全代码的一个真子集,这意味着相对线程安全来说,可重入性是更为基础的特性,它可以保证代码线程安全,即所有可重入的代码都是线程安全的,但并非所有的线程安全的代码都是可重入的。

可重入代码有一些共同的特征,例如,不依赖全局变量、存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入,不调用非可重入的方法等。我们可以通过一个比较简单的原则来判断代码是否具备可重入性:如果一个方法的返回结果是可以预测的,只要输入了相同的数据,就都能返回相同的结果,那它就满足可重入性的要求,当然也就是线程安全的。

线程本地存储(Thread Local Storage):如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。

符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程限制在一个线程中消费完,其中最重要的一种应用实例就是经典Web交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多Web服务端应用都可以使用线程本地存储来解决线程安全问题。

Java语言中,如果一个变量要被多线程访问,可以使用volatile关键字将它声明为“易变的”;如果一个变量只要被某个线程独享,Java中就没有类似C++中__declspec(thread)这样的关键字去修饰,不过我们还是可以通过java.lang.ThreadLocal类来实现线程本地存储的功能。每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口,每一个 ThreadLocal对象都包含了一个独一无二的threadLocalHashCode值,使用这个值就可以在线程K-V值对中找回对应的本地线程变量。

[1] JSR 166:Concurrency Utilities。
[2] 还有另外一种常见的实现——重入读写锁(ReentrantReadWriteLock,尽管名字看起来很像,但它并不是ReentrantLock的子类),由于本书的主题是Java虚拟机和不是Java并发编程,因此仅以ReentrantLock为例来进行讲解,ReentrantReadWriteLock就不再介绍了。
[3] 可重入性是指一条线程能够反复进入被它自己持有锁的同步块的特性,即锁关联的计数器,如果持有锁的线程再次获得它,则将计数器的值加一,每次释放锁时计数器的值减一,当计数器的值为零时,才能真正释放锁。

7.4 锁优化

在这里插入图片描述

7.4.1 synchronized 锁优化的背景

用锁能够实现数据的安全性,但是会带来性能下降。
无锁能够基于线程并行提升程序性能,但是会带来安全性下降。
要在性能与安全找到平衡点:jdk6引入偏向锁、轻量级锁

Synchronized的性能变化
Java5之前,用户态和内核态之间的切换
java5以前,只有Synchronized,这个是操作系统级别的重量级操作
重量级锁,假如锁的竞争比较激烈的话,性能下降‘

在这里插入图片描述

java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。

在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”,时间成本相对较高,这也是为什么早期的synchronized效率低的原因
Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁

java6开始,优化Synchronized

Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级不能降级的策略,目的是为了提高获得锁和释放锁的效率

在这里插入图片描述

synchronized锁:由对象头中的Mark Word根据锁标志位的不同而被复用及锁升级策略
在这里插入图片描述

7.4.2 锁升级

synchronized用的锁是存在Java对象头里的Mark Word中
锁升级功能主要依赖MarkWord中锁标志位和释放偏向锁标志位
锁的4种状态:无锁状态、偏向锁状态、轻量级锁状态(自旋锁。自适应自旋锁)、重量级锁状态(级别从低到高)
锁机制就是根据资源竞争的激烈程度不断进行锁升级的过程

在这里插入图片描述

在这里插入图片描述

7.4.3 无锁

maven引入JOL
<!--JOL   Java Object Layout Java对象布局-->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>
package com.dongguo.lockupgrade;

import org.openjdk.jol.info.ClassLayout;

/**
 * @author Dongguo
 * @date 2021/9/8 0008-21:46
 * @description: 无锁
 */
public class MyObject {
    public static void main(String[] args) {
        Object o = new Object();

        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}
运行结果
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
01 00 00 00 (00000001 00000000 00000000 00000000) (1)
00000001 00000000 00000000 00000000 二进制需要倒着看(每8个位看做一个整体) 1 2 3 4 变成 4 3 2 1
00000000 00000000 00000000 00000(001)

在这里插入图片描述

此时hashcode是0
因为懒加载的缘故,使用到hashcode才会初始化

package com.dongguo.lockupgrade;
import org.openjdk.jol.info.ClassLayout;

/**
 * @author Dongguo
 * @date 2021/9/8 0008-21:46
 * @description:
 */
public class MyObject {
    public static void main(String[] args) {
        Object o = new Object();
        System.out.println("10进制hash码:"+o.hashCode());
        System.out.println("16进制hash码:"+Integer.toHexString(o.hashCode()));
        System.out.println("2进制hash码:"+Integer.toBinaryString(o.hashCode()));

        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}
运行结果
10进制hash码:1265094477
16进制hash码:4b67cf4d
2进制hash码:1001011011001111100111101001101
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 4d cf 67 (00000001 01001101 11001111 01100111) (1741638913)
      4     4        (object header)                           4b 00 00 00 (01001011 00000000 00000000 00000000) (75)
      8     4        (object header)                           e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
00000001 01001101 11001111 01100111 01001011
倒着看 310(1001011 01100111 11001111 01001101) 00000001
2进制hash码: 10010110110011111001111010011010(1001011 01100111 11001111 01001101) 00000001
去掉后801001101 11001111 01100111 01001011
hashcode是311001101 11001111 01100111 01001011

7.4.4 偏向锁

偏向锁偏向于第一个获得它的线程,默认不存在锁竞争的情况下,常常是一个线程多次获得同一个锁,重复获取同一把锁不会再进行锁的竞争,
看看多线程卖票,同一个线程获得体会一下

package com.dongguo.lockupgrade;

/**
 * @author Dongguo
 * @date 2021/9/3 0003-10:14
 * @description: 实现3个售票员卖出50张票的案例
 */
public class SaleTicket {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        new Thread(() -> {
            //循环100次保证能够卖光票
            for (int i = 0; i < 100; i++) {
                ticket.saleTicket();
            }
        }, "T1").start();
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                ticket.saleTicket();
            }
        }, "T2").start();
        new Thread(() -> {
            for (int i = 0; i < 100; i++) {
                ticket.saleTicket();
            }
        }, "T3").start();
    }
}

/**
 * @author Dongguo
 * @description: 资源类
 */
class Ticket {
    private int count = 50;

    public synchronized void saleTicket() {
        if (count > 0) {
            count--;
            System.out.println(Thread.currentThread().getName() + "卖票成功,还剩" + count + "张票!");
        }
    }
}
运行结果
T1卖票成功,还剩49张票!
T1卖票成功,还剩48张票!
T1卖票成功,还剩47张票!
T1卖票成功,还剩46张票!
T1卖票成功,还剩45张票!
T1卖票成功,还剩44张票!
T1卖票成功,还剩43张票!
T1卖票成功,还剩42张票!
T1卖票成功,还剩41张票!
T1卖票成功,还剩40张票!
T1卖票成功,还剩39张票!
T1卖票成功,还剩38张票!
T1卖票成功,还剩37张票!
T1卖票成功,还剩36张票!
T1卖票成功,还剩35张票!
T1卖票成功,还剩34张票!
T1卖票成功,还剩33张票!
T1卖票成功,还剩32张票!
T1卖票成功,还剩31张票!
T1卖票成功,还剩30张票!
T1卖票成功,还剩29张票!
T1卖票成功,还剩28张票!
T1卖票成功,还剩27张票!
T1卖票成功,还剩26张票!
T1卖票成功,还剩25张票!
T1卖票成功,还剩24张票!
T1卖票成功,还剩23张票!
T1卖票成功,还剩22张票!
T1卖票成功,还剩21张票!
T1卖票成功,还剩20张票!
T1卖票成功,还剩19张票!
T1卖票成功,还剩18张票!
T1卖票成功,还剩17张票!
T1卖票成功,还剩16张票!
T1卖票成功,还剩15张票!
T1卖票成功,还剩14张票!
T1卖票成功,还剩13张票!
T1卖票成功,还剩12张票!
T1卖票成功,还剩11张票!
T1卖票成功,还剩10张票!
T1卖票成功,还剩9张票!
T1卖票成功,还剩8张票!
T1卖票成功,还剩7张票!
T1卖票成功,还剩6张票!
T1卖票成功,还剩5张票!
T1卖票成功,还剩4张票!
T1卖票成功,还剩3张票!
T1卖票成功,还剩2张票!
T1卖票成功,还剩1张票!
T1卖票成功,还剩0张票!

发现全是t1卖出 这样就是偏向锁的情况

7.4.5 为什么要引入偏向锁?

Hotspot 的作者经过研究发现,大多数情况下:
多线程的情况下,锁不仅不存在多线程竞争,还存在锁由同一线程多次获得的情况,
为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁就是在这种情况下出现的,它的出现是为了解决只有在一个线程执行同步时提高性能。
即为了降低获取锁的代价,才引入的偏向锁。

在这里插入图片描述

通过CAS方式修改markword中的线程ID

7.4.6 偏向锁的持有

理论落地:
在实际应用运行过程中发现,“锁总是同一个线程持有,很少发生竞争”,也就是说锁总是被第一个占用他的线程拥有,这个线程就是锁的偏向线程。
那么只需要在锁第一次被拥有的时候,记录下偏向线程ID。这样偏向线程就一直持有着锁(后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁)。
如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了,直到竞争发生才释放锁。以后每次同步,检查锁的偏向线程ID与当前线程ID是否一致,如果一致直接进入同步。无需每次加锁解锁都去CAS更新对象头。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
假如不一致意味着发生了竞争,锁已经不是总是偏向于同一个线程了,这时候可能需要升级变为轻量级锁,才能保证线程间公平竞争锁。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程是不会主动释放偏向锁的。

技术实现:
一个synchronized方法被一个线程抢到了锁时,那这个方法所在的对象就会在其所在的Mark Word中将偏向锁修改状态位,同时还会有占用前54位来存储线程指针作为标识。若该线程再次访问同一个synchronized方法时,该线程只需去对象头的Mark Word 中去判断一下是否有偏向锁指向本身的ID,无需再进入 Monitor 去竞争对象了。

举例说明
偏向锁的操作不用直接捅到操作系统,不涉及用户到内核转换,不必要直接升级为最高级,我们以一个account对象的“对象头”为例,
在这里插入图片描述
假如有一个线程执行到synchronized代码块的时候,JVM使用CAS操作把线程指针ID记录到Mark Word当中,并修改标偏向标示,标示当前线程就获得该锁。锁对象变成偏向锁(通过CAS修改对象头里的锁标志位),字面意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁。
在这里插入图片描述
这时线程获得了锁,可以执行同步代码块。当该线程第二次到达同步代码块时会判断此时持有锁的线程是否还是自己(持有锁的线程ID也在对象头里),JVM通过account对象的Mark Word判断:当前线程ID还在,说明还持有着这个对象的锁,就可以继续进入临界区工作。由于之前没有释放锁,这里也就不需要重新加锁。 如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。

结论:JVM不用和操作系统协商设置Mutex(争取内核),它只需要记录下线程ID就标示自己获得了当前锁,不用操作系统接入。
上述就是偏向锁:在没有其他线程竞争的时候,一直偏向偏心当前线程,当前线程可以一直执行。

7.4.7 偏向锁JVM命令

查出BiasedLock相关的参数设置

java -XX:+PrintFlagsInitial |grep BiasedLock*

linux命令
在这里插入图片描述

windows cmd命令
在这里插入图片描述
默认偏向锁是打开的 UseBiasedLocking = true
但是BiasedLockingStartupDelay =4000 偏向锁 启动时间有延迟,

* 实际上偏向锁在JDK1.6之后是默认开启的,但是启动时间有延迟,
* 如有必要可以使用JVM参数来关闭延迟
* 所以需要添加参数-XX:BiasedLockingStartupDelay=0,让其在程序启动时立刻启动。
*
* 开启偏向锁:
* -XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
*
* 如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁
* 关闭偏向锁:关闭之后程序默认会直接进入------------------------------------------>>>>>>>>   轻量级锁状态。那么程序默认会进入轻量级锁状态
* -XX:-UseBiasedLocking

使用默认设置 默认有延迟时间

package com.dongguo.lockupgrade;

import org.openjdk.jol.info.ClassLayout;

/**
 * @author Dongguo
 * @date 2021/9/8 0008-21:46
 * @description:
 */
public class MyObject {
    public static void main(String[] args) {
        Object object = new Object();

        new Thread(() -> {
            synchronized (object) {
                System.out.println(ClassLayout.parseInstance(object).toPrintable());
            }
        }, "t1").start();

    }
}
运行结果
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           60 f6 ef cd (01100000 11110110 11101111 11001101) (-839911840)
      4     4        (object header)                           b5 00 00 00 (10110101 00000000 00000000 00000000) (181)
      8     4        (object header)                           e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

由于延迟4秒钟,锁信息显示出来的是不正确的 后8位字节01100000,是轻量级锁状态
2设置延迟时间为0 -XX:BiasedLockingStartupDelay=0

package com.dongguo.lockupgrade;

import org.openjdk.jol.info.ClassLayout;

/**
 * @author Dongguo
 * @date 2021/9/8 0008-21:46
 * @description:
 */
public class MyObject {
    public static void main(String[] args) {
        Object object = new Object();

        new Thread(() -> {
            synchronized (object) {
                System.out.println(ClassLayout.parseInstance(object).toPrintable());
            }
        }, "t1").start();
    }
}
运行结果
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 70 f3 b2 (00000101 01110000 11110011 10110010) (-1292668923)
      4     4        (object header)                           2a 02 00 00 (00101010 00000010 00000000 00000000) (554)
      8     4        (object header)                           e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

后8位00000101 锁信息 101 即偏向锁
1表示为偏向锁位
01表示为锁标记位

在这里插入图片描述

7.4.8 偏向锁的升级

(一个线程持有锁,第二个线程加入锁竞争)
如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会将偏向锁升级为轻量级锁。
当有另外线程逐步来竞争锁的时候,就不能再使用偏向锁了,要升级为轻量级锁
竞争线程尝试CAS更新对象头失败,会等待到全局安全点(STW,此时不会执行任何代码)撤销偏向锁。
偏向锁使用一种等到竞争出现才释放锁的机制,只有当其他线程竞争锁时,持有偏向锁的原来线程才会释放锁。
偏向锁的撤销需要等待全局安全点(在这个时间点上没有正在执行的字节码 ,JVM中的STW的概念 ),
它会首先暂停拥有偏向锁的线程, 同时检查持有偏向锁的线程是否还在执行:
① 第一个线程正在执行synchronized方法(处于同步块),它还没有执行完,其它线程来抢夺,暂停第一个线程,该偏向锁会被取消掉并出现锁升级。
此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
② 第一个线程执行完成synchronized方法(退出同步块),第二个线程执行重新偏向 。
如果第一个线程退出,当第一个线程不存在了,第二个线程执行,CAS将当前线程指针修改,仍为偏向锁状态
如果第一个线程退出还要再次执行,第二个线程也要执行,那么两个线程就抢夺轻量级锁,没抢到的进行自旋

在这里插入图片描述

此时升级为轻量级锁

7.4.9 调用对象 hashCode导致偏向锁撤销

调用了对象的 hashCode,但偏向锁的对象 MarkWord 中存储的是线程 id,如果调用 hashCode 会导致偏向锁被撤销变为无锁状态
轻量级锁会在锁记录中记录 hashCode
重量级锁会在 Monitor 中记录 hashCode

在获得偏向锁后,调用 hashCode

package com.dongguo.lockupgrade;

import org.openjdk.jol.info.ClassLayout;

/**
 * @author Dongguo
 * @date 2021/9/11 0011-18:06
 * @description: -XX:BiasedLockingStartupDelay=0
 */
public class RedoLockDemo {
    public static void main(String[] args) {
        Object object = new Object();

        new Thread(() -> {
            synchronized (object) {
                System.out.println(ClassLayout.parseInstance(object).toPrintable());
            }
            object.hashCode();
            System.out.println(ClassLayout.parseInstance(object).toPrintable());
        }, "t1").start();
    }
}
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 f8 5d dd (00000101 11111000 01011101 11011101) (-581044219)
      4     4        (object header)                           4b 02 00 00 (01001011 00000010 00000000 00000000) (587)
      8     4        (object header)                           e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 ea 67 be (00000001 11101010 01100111 10111110) (-1100486143)
      4     4        (object header)                           66 00 00 00 (01100110 00000000 00000000 00000000) (102)
      8     4        (object header)                           e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

101 偏向锁变为001无锁

7.4.10 调用 wait/notify升级为重量级锁

如果线程获得锁后调用Object#wait方法,则会将线程加入到WaitSet中,当被Object#notify唤醒后,会将线程从WaitSet移动到cxq或EntryList中去。
一个ObjectMonitor对象包括这么几个关键字段:cxq(下图中的ContentionList),EntryList ,WaitSet,owner。
其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的链表结构,owner指向持有锁的线程。
————————————————

在这里插入图片描述

需要注意的是,当调用一个锁对象的wait或notify方法时,如当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。

package com.dongguo.lockupgrade;

import org.openjdk.jol.info.ClassLayout;

import java.util.concurrent.TimeUnit;

/**
 * @author Dongguo
 * @date 2021/9/11 0011-18:06
 * @description:
 */
public class RedoLockDemo {
    public static void main(String[] args) {
        Object object = new Object();

        Thread t1 = new Thread(() -> {
            synchronized (object) {
                try {
                    System.out.println(ClassLayout.parseInstance(object).toPrintable());
                    object.wait();
                    System.out.println("被唤醒");

                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println(ClassLayout.parseInstance(object).toPrintable());

        }, "t1");
        t1.start();
        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (object) {
                object.notifyAll();
                System.out.println("notifyAll");
            }
        }, "t2").start();
    }
}
运行结果
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           05 40 22 62 (00000101 01000000 00100010 01100010) (1646411781)
      4     4        (object header)                           ec 01 00 00 (11101100 00000001 00000000 00000000) (492)
      8     4        (object header)                           e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

notifyAll
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           da 9d 38 61 (11011010 10011101 00111000 01100001) (1631100378)
      4     4        (object header)                           ec 01 00 00 (11101100 00000001 00000000 00000000) (492)
      8     4        (object header)                           e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total  

从101偏向锁变为010重量级锁

7.4.11 批量重偏向与批量撤销

通过JVM的默认参数值,找一找批量重偏向和批量撤销的阈值。
设置JVM参数-XX:+PrintFlagsFinal,在项目启动时即可输出JVM的默认参数值
intx BiasedLockingBulkRebiasThreshold = 20 默认偏向锁批量重偏向阈值
intx BiasedLockingBulkRevokeThreshold = 40 默认偏向锁批量撤销阈值
当然我们可以通过-XX:BiasedLockingBulkRebiasThreshold 和 -XX:BiasedLockingBulkRevokeThreshold 来手动设置阈值

在这里插入图片描述

7.4.12 批量重偏向

7.4.13 批量撤销

7.4.14 轻量级锁(竞争不激烈)

为什么要引入轻量级锁?
在竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。
即一个对象虽然有多哥线程访问,但是多个线程的访问是错开的(存在少量竞争会不存在竞争)

轻量级锁的获取
轻量级锁是为了在线程近乎交替执行同步块时提高性能。
主要目的: 在没有多线程竞争的前提下,通过CAS减少重量级锁使用操作系统互斥量产生的性能消耗,说白了先自旋再阻塞。

升级时机: 当关闭偏向锁功能或多线程竞争偏向锁会导致偏向锁升级为轻量级锁

假如线程A已经拿到锁,这时线程B又来抢该对象的锁,由于该对象的锁已经被线程A拿到,当前该锁已是偏向锁了。
而线程B在争抢时发现对象头Mark Word中的线程ID不是线程B自己的线程ID(而是线程A),那线程B就会进行CAS操作希望能获得锁。
此时线程B操作中有两种情况:
如果锁获取成功,直接替换Mark Word中的线程ID为B自己的ID(A → B),重新偏向于其他线程(即将偏向锁交给其他线程,相当于当前线程"被"释放了锁),该锁会保持偏向锁状态,A线程Over,B线程上位;

在这里插入图片描述

如果锁获取失败,则偏向锁升级为轻量级锁,此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程B会进入自旋等待获得该轻量级锁。

在这里插入图片描述

7.4.15 测试轻量级锁

如果关闭偏向锁,就可以直接进入轻量级锁
在这里插入图片描述

package com.dongguo.lockupgrade;

import org.openjdk.jol.info.ClassLayout;

/**
 * @author Dongguo
 * @date 2021/9/8 0008-21:46
 * @description:
 */
public class MyObject {
    public static void main(String[] args) {
        Object object = new Object();

        new Thread(() -> {
            synchronized (object) {
                System.out.println(ClassLayout.parseInstance(object).toPrintable());
            }
        }, "t1").start();
    }
}
运行结果
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           40 ef 6f 0a (01000000 11101111 01101111 00001010) (175107904)
      4     4        (object header)                           6e 00 00 00 (01101110 00000000 00000000 00000000) (110)
      8     4        (object header)                           e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

后8位01000000
轻量级锁 最后两位00

7.4.16、轻量级锁什么时候升级为重量级锁?

(在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。自旋有限度的(有个计数器记录自旋次数,默认允许循环10次,可以通过虚拟机参数更改)。

java6之前是自旋锁
默认启用,默认情况下自旋的次数是 10 次
-XX:PreBlockSpin=10来修改
或者自旋线程数超过cpu核数一半

Java6之后 是自适应自旋锁
自适应意味着自旋的次数不是固定不变的
根据同一个锁上一次自旋的时间。和拥有锁线程的状态来决定。
长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。

轻量锁与偏向锁的区别和不同
争夺轻量级锁失败时,线程自旋尝试抢占锁
轻量级锁每次退出同步块都需要释放锁,而偏向锁是在竞争发生时才释放锁

重量级锁
有大量的线程参与锁的竞争,冲突性很高,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁。
当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接进入阻塞状态(而不是忙等),等待被唤醒。)

在这里插入图片描述

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级
成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,
都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮
的夺锁之争。

7.4.17、自旋优化

在这里插入图片描述

自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
Java 7 之后不能控制是否开启自旋功能。

各种锁优缺点、synchronized锁升级和实现原理
*注意:锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态。

在这里插入图片描述

synchronized锁升级过程总结:一句话,就是先自旋,不行再阻塞。
实际上是把之前的悲观锁(重量级锁)变成在一定条件下使用偏向锁以及使用轻量级(自旋锁CAS)的形式

synchronized在修饰方法和代码块在字节码上实现方式有很大差异,但是内部实现还是基于对象头的MarkWord来实现的。
JDK1.6之前synchronized使用的是重量级锁,JDK1.6之后进行了优化,拥有了无锁->偏向锁->轻量级锁->重量级锁的升级过程,而不是无论什么情况都使用重量级锁。

偏向锁:适用于单线程适用的情况,在不存在锁竞争的时候进入同步方法/代码块则使用偏向锁。

轻量级锁:适用于竞争较不激烈的情况(这和乐观锁的使用范围类似), 存在竞争时升级为轻量级锁,轻量级锁采用的是自旋锁,如果同步方法/代码块执行时间很短的话,采用轻量级锁虽然会占用cpu资源但是相对比使用重量级锁还是更高效。

重量级锁:适用于竞争激烈的情况,如果同步方法/代码块执行时间很长,那么使用轻量级锁自旋带来的性能消耗就比使用重量级锁更严重,这时候就需要升级为重量级锁。

![JIT编译器对锁的优化
JIT
Just In Time Compiler,一般翻译为即时编译器

7.4.18、锁粗化

按理来说,同步块的作用范围应该尽可能小,但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。
锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。

例子1

在这里插入图片描述

JVM会检测到这样一连串的操作都对同一个对象加锁(while循环内100次执行append,没有锁粗话就要进行100次加锁/解锁) ,此时JVM就会将加锁的范围粗化到这一连串的操作的外部(比如while循环体外) ,使得这一连串操作只需要加一次锁即可。

而且JIT编译器在编译期对StringBuffer对象进行逃逸分析,如果没有发生逃逸,则会使用栈上分配,分配完成后,继续在调用栈内执行,线程结束后,栈空间被回收,局部变量对象也被回收,这样就不用进行垃圾回收了。可以减少垃圾回收时间和次数。

例子2

package com.dongguo.lockupgrade;

/**
 * @author Dongguo
 * @date 2021/9/9 0009-10:37
 * @description: 锁粗化
 * 假如方法中首尾相接,前后相邻的都是同一个锁对象,那JIT编译器就会把这几个synchronized块合并成一个大块,
 * 加粗加大范围,一次申请锁使用即可,避免次次的申请和释放锁,提升了性能
 */
public class LockBigDemo {
    static Object objectLock = new Object();


    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (objectLock) {
                System.out.println("11111");
            }
            synchronized (objectLock) {
                System.out.println("22222");
            }
            synchronized (objectLock) {
                System.out.println("33333");
            }
        }, "a").start();

        new Thread(() -> {
            synchronized (objectLock) {
                System.out.println("44444");
            }
            synchronized (objectLock) {
                System.out.println("55555");
            }
            synchronized (objectLock) {
                System.out.println("66666");
            }
        }, "b").start();
    }
}
 

7.4.17、锁消除

Java虚拟机通过对运行上下文的扫描,经过逃逸分析,去除不可能存在竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间
例子1

在这里插入图片描述

StringBuffer是线程安全的 ,因为它的关键方法都被synchronized修饰过的,但是我们看上面代码,我们会发现,sb这个引用只会在add方法中使用,不可能被其它线程引用(因为是局部变量,栈私有),因此sb是不可能共享的资源,JVM会自动消除StringBuffer对象内部的锁
例子2
package com.dongguo.lockupgrade;

/**

  • @author Dongguo
  • @date 2021/9/9 0009-10:29
  • @description: 锁消除
  • 从JIT角度看相当于无视它,synchronized (o)不存在了,这个锁对象并没有被共用扩散到其它线程使用,
  • 极端的说就是根本没有加这个锁对象的底层机器码,消除了锁的使用
    */
public class LockClearUPDemo {
    static Object objectLock = new Object();//正常的

    public void m1() {
        //锁消除,JIT会无视它,synchronized(对象锁)不存在了。不正常的
        //每个线程调用m1方法都会创建一个新的object   即使用的不是同一把锁,不存在锁竞争
        Object o = new Object();

        synchronized (o) {
            System.out.println("-----hello LockClearUPDemo" + "\t" + o.hashCode() + "\t" + objectLock.hashCode());
        }
    }

    public static void main(String[] args) {
        LockClearUPDemo demo = new LockClearUPDemo();

        for (int i = 1; i <= 10; i++) {
            new Thread(() -> {
                demo.m1();
            }, String.valueOf(i)).start();
        }
    }
}

实质是JIT编译器的同步省略或者叫同步消除
锁降级
一般说是不存在锁降级的

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

吹老师个人app编程教学

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值