Java的锁机制。乐观锁/悲观锁、公平锁/非公平锁、可重入锁、独占锁/共享锁、互斥锁/读写锁、分段锁、锁的状态(偏向锁、轻量级锁、重量级锁)、自旋锁。Java中8种锁机制,一步到位!

Java锁的机制

一、公平锁/非公平锁

​ 在ReentrantLock中包含了公平锁非公平锁两种锁。 如果你用默认的构造函数来创建ReentrantLock对象,默认的锁策略就是非公平的。

1、公平锁

公平锁定义:

多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

公平锁优缺点:
  • 优点:所有的线程都能得到资源,不会饿死在队列中。
  • 缺点:吞吐量会下降很多,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销会很大。
公平锁图示:

图示1:

​ 咱们重新回到第一张图,就是线程1刚刚释放锁之后,线程2还没来得及重新加锁的那个状态

img

同样,这时假设来了一个线程3,突然杀出来,想要加锁。

如果是公平锁的策略,那么此时线程3不会跟个愣头青一样盲目的直接加锁。

他会先判断一下:咦?AQS的等待队列里,有没有人在排队啊?如果有人在排队的话,说明我前面有兄弟正想要加锁啊!

如果AQS的队列里真的有线程排着队,那我线程3就不能跟个二愣子一样直接抢占加锁了。

因为现在咱们是公平策略,得按照先来后到的顺序依次排队,谁先入队,谁就先从队列里出来加锁!

所以,线程3此时一判断,发现队列里有人排队,自己就会乖乖的排到队列后面去,而不会贸然加锁!

同样,整个过程我们用下面这张图给大家直观的展示一下:

img

上面的等待队列中,线程3会按照公平原则直接进入队列尾部进行排队。

接着,线程2不是被唤醒了么?他就会重新尝试进行CAS加锁,此时没人跟他抢,他当然可以加锁成功了。

然后呢,线程2就会将state值变为1,同时设置“加锁线程”是自己。最后,线程2自己从等待队列里出队。

整个过程,参见下图:

img

图示2:

​ 现在是早餐时间,敖丙想去kfc搞个早餐,发现有很多人了,一过去没多想,就乖乖到队尾排队,这样大家都觉得很公平,先到先得,所以这是公平锁咯。

img

2、非公平锁

非公平锁定义:

​ 多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

非公平锁优缺点:
  • 优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
  • 缺点:你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁,导致饿死。
非公平锁图示:

图示1:

​ 先来聊聊非公平锁是啥,现在大家先回过头来看下面这张图。

img

如上图,现在线程1加了锁,然后线程2尝试加锁,失败后进入了等待队列,处于阻塞中。然后线程1释放了锁,准备来唤醒线程2重新尝试加锁。

注意一点,此时线程2可还停留在等待队列里啊,还没开始尝试重新加锁呢!

然而,不幸的事情发生了,这时半路杀出个程咬金,来了一个线程3!线程3突然尝试对ReentrantLock发起加锁操作,此时会发生什么事情?

很简单!线程2还没来得及重新尝试加锁呢。也就是说,还没来得及尝试重新执行CAS操作将state的值从0变为1呢!线程3冲上来直接一个CAS操作,尝试将state的值从0变为1,结果还成功了!

一旦CAS操作成功,线程3就会将“加锁线程”这个变量设置为他自己。给大家来一张图,看看这整个过程:

img

明明人家线程2规规矩矩的排队领锁呢,结果你线程3不守规矩,线程1刚释放锁,不分青红皂白,直接就跑过来抢先加锁了。

这就导致线程2被唤醒过后,重新尝试加锁执行CAS操作,结果毫无疑问,失败!

原因很简单啊!因为加锁CAS操作,是要尝试将state从0变为1,结果此时state已经是1了,所以CAS操作一定会失败!

一旦加锁失败,就会导致线程2继续留在等待队列里不断的等着,等着线程3释放锁之后,再来唤醒自己,真是可怜!先来的线程2居然加不到锁!

同样给大家来一张图,体会一下线程2这无助的过程:

img

​ 上述的锁策略,就是所谓的非公平锁!

如果你用默认的构造函数来创建ReentrantLock对象,默认的锁策略就是非公平的。

在非公平锁策略之下,不一定说先来排队的线程就就先会得到机会加锁,而是出现各种线程随意抢占的情况。

图示2:

​ 那非公平锁就是,敖丙过去买早餐,发现大家都在排队,但是敖丙这个人有点渣的,就是喜欢插队,那他就直接怼到第一位那去,后面的鸡蛋,米豆都不行,我插队也不敢说什么,只能默默忍受了。

img

但是偶尔,鸡蛋也会崛起,叫我滚到后面排队,我也是欺软怕硬,默默到后面排队,就插队失败了。

img

二、可重入锁

1、可重入锁定义

	可重入就是说某个线程已经获得某个锁,可以再次获取锁而不会出现死锁。 降低了编程复杂性。可重入锁诞生的目的就是:让同一个线程可以重新进入上锁代码段。 

2、代码示例:

package com.test.reen;
// 演示可重入锁是什么意思,可重入,就是可以重复获取相同的锁,synchronized和ReentrantLock都是可重入的
// 可重入降低了编程复杂性
public class WhatReentrant {
	public static void main(String[] args) {
		new Thread(new Runnable() {
			@Override
			public void run() {
				synchronized (this) {
					System.out.println("第1次获取锁,这个锁是:" + this);
					int index = 1;
					while (true) {
						synchronized (this) {
							System.out.println("第" + (++index) + "次获取锁,这个锁是:" + this);
						}
						if (index == 10) {
							break;
						}
					}
				}
			}
		}).start();
	}
}
//---------------------------------------------------------------------------------------------------
package com.test.reen;
import java.util.Random;
import java.util.concurrent.locks.ReentrantLock;

// 演示可重入锁是什么意思
public class WhatReentrant2 {
	public static void main(String[] args) {
		ReentrantLock lock = new ReentrantLock();
		
		new Thread(new Runnable() {
			@Override
			public void run() {
				try {
					lock.lock();
					System.out.println("第1次获取锁,这个锁是:" + lock);

					int index = 1;
					while (true) {
						try {
							lock.lock();
							System.out.println("第" + (++index) + "次获取锁,这个锁是:" + lock);
							try {
								Thread.sleep(new Random().nextInt(200));
							} catch (InterruptedException e) {
								e.printStackTrace();
							}
							if (index == 10) {
								break;
							}
						} finally {
							lock.unlock();
						}
					}
				} finally {
					lock.unlock();
				}
			}
		}).start();
	}
}
//可以发现没发生死锁,可以多次获取相同的锁

​ Java的可重入锁有: ReentrantLock(显式的可重入锁)synchronized(隐式的可重入锁)

3、使用ReentrantLock的注意点

​ ReentrantLock 和 synchronized 不一样,需要手动释放锁,所以使用 ReentrantLock的时候一定要手动释放锁,并且加锁次数和释放次数要一样

代码示例:

package com.test.reen;
import java.util.Random;
import java.util.concurrent.locks.ReentrantLock;

public class WhatReentrant3 {
	public static void main(String[] args) {
		ReentrantLock lock = new ReentrantLock();
		
		new Thread(new Runnable() {
			@Override
			public void run() {
				try {
					lock.lock();
					System.out.println("第1次获取锁,这个锁是:" + lock);

					int index = 1;
					while (true) {
						try {
							lock.lock();
							System.out.println("第" + (++index) + "次获取锁,这个锁是:" + lock);	
							try {
								Thread.sleep(new Random().nextInt(200));
							} catch (InterruptedException e) {
								e.printStackTrace();
							}	
							if (index == 10) {
								break;
							}
						} finally {
//							lock.unlock();// 这里故意注释,实现加锁次数和释放次数不一样
						}
					}
				} finally {
					lock.unlock();
				}
			}
		}).start();
		
		new Thread(new Runnable() {
			@Override
			public void run() {
				try {
					lock.lock();
					for (int i = 0; i < 20; i++) {
						System.out.println("threadName:" + Thread.currentThread().getName());
						try {
							Thread.sleep(new Random().nextInt(200));
						} catch (InterruptedException e) {
							e.printStackTrace();
						}
					}
				} finally {
					lock.unlock();
				}
			}
		}).start();
	}
}
//---------------------------------------------------------------------------------------------------
//由于加锁次数和释放次数不一样,第二个线程始终无法获取到锁,导致一直在等待。
//稍微改一下,在外层的finally里头释放9次,让加锁和释放次数一样,就没问题了
try {
	lock.lock();
	System.out.println("第1次获取锁,这个锁是:" + lock);

	int index = 1;
	while (true) {
		try {
			lock.lock();
			System.out.println("第" + (++index) + "次获取锁,这个锁是:" + lock);
			
			... 代码省略节省篇幅...
		} finally {
//							lock.unlock();// 这里故意注释,实现加锁次数和释放次数不一样
		}
	}
} finally {
	lock.unlock();
	// 在外层的finally里头释放9次,让加锁和释放次数一样,就没问题了
	for (int i = 0; i < 9; i++) {
		lock.unlock();
	}
}

三、独占锁/共享锁

1、独占锁

​ 独占锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排他锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。很显然,独占锁是一种悲观保守的加锁策略,它避免了读/读冲突,如果某个只读线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发性,因为读操作并不会影响数据的一致性。JDK中的 synchronized 和 JUC中 ReentrantLock 的实现类就是互斥锁。

独占锁的特点:读读互斥,读写互斥,写写互斥

2、共享锁

​ 共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。共享锁是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源。 java的并发包中提供了ReadWriteLock读写锁。它允许一个资源可以被多个读操作访问,或者被一个写操作访问,但两者不能同时进行。

在这里插入图片描述

​ 我们看到 ReentrantReadWriteLock 有两把锁:ReadLock和WriteLock,见名知意,一个读锁一个写锁, 合称“读写锁”。再进一步观察可以发现 ReadLock 和 WriteLock 是靠内部类 Sync 实现的锁。Sync 是 AQS 的一个子类,这种结构在 CountDownLatch 、ReentrantLock 、Semaphore 里面也都存在。在ReentrantReadWriteLock 里面,读锁和写锁的锁主体都是 Sync ,但读锁和写锁的加锁方式不一样。读锁是共享锁,写锁是独占锁。读锁的共享锁可保证并发读非常高效,而读写、写读、写写的过程互斥,因为读锁和写锁是分离的。所以ReentrantReadWriteLock的并发性相比一般的互斥锁有了很大提升。

四、互斥锁/读写锁

1、互斥锁

​ 在线程里也有这么一把锁——互斥锁(mutex),互斥锁是一种简单的加锁的方法来控制对共享资源的访问,它只有两个状态,要么是加锁状态(lock),要么是不加锁状态(unlock)。在多任务操作系统中,同时运行的多个任务可能都需要使用同一种资源。假如现在一个线程A只是想读一个共享变量 X,因为不确定是否会有线程去写它,所以我们还是要对它进行加锁。但是这时又有一个线程B试图去读共享变量 X,发现被锁定了,那么B不得不等到A释放了锁后才能获得锁并读取 X 的值,但是两个读取操作即使是同时发生的,也并不会像写操作那样造成竞争,因为它们不修改变量的值。所以我们期望在多个线程试图读取共享变量的时候,它们可以立刻获取因为读而加的锁,而不是需要等待前一个线程释放。

互斥锁的特点:
  1. 原子性:把一个互斥量锁定为一个原子操作,这意味着操作系统(或pthread函数库)保证了如果一个线程锁定了一个互斥量,没有其他线程在同一时间可以成功锁定这个互斥量;
  2. 唯一性:如果一个线程锁定了一个互斥量,在它解除锁定之前,没有其他线程可以锁定这个互斥量;
  3. 非繁忙等待:如果一个线程已经锁定了一个互斥量,第二个线程又试图去锁定这个互斥量,则第二个线程将被挂起(不占用任何cpu资源),直到第一个线程解除对这个互斥量的锁定为止,第二个线程则被唤醒并继续执行,同时锁定这个互斥量。
互斥锁的操作流程:
  1. 在访问共享资源后临界区域前,对互斥锁进行加锁;

  2. 在访问完成后释放互斥锁导上的锁。在访问完成后释放互斥锁导上的锁;

  3. 对互斥锁进行加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放。对互斥锁进行加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放。

2、读写锁

​ 读写锁与互斥量类似,它提供了比互斥锁更好的更改的并行性,但速度一定比互斥锁快,读写锁更复杂,系统开销更大。并发性好对于用户体验非常重要,假设互斥锁需要0.5秒,读写锁需要0.8秒,在类似学生管理系统的软件中,可能90%的操作都是查询操作。如果突然有20个查询请求,使用的是互斥锁,则最后的查询请求被满足需要10秒,估计没人接收。使用读写锁时,因为读锁能多次获得,所以20个请求中,每个请求都能在1秒左右被满足,用户体验好的多。绝大部分情况下,读操作远比写操作多,读写锁就是为了这种优化而创建出来的一种机制。

**读写锁的特点:**读读共享,读写互斥,写写互斥

五、乐观锁/悲观锁

1、乐观锁

​ 顾名思义,就是很乐观。总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

实现方式

乐观锁的实现方式主要有两种:CAS机制 、 版本号机制 。

CAS

​ CAS (Compare And Swap,比较与交换),是一种支持硬件层次的原子性操作的经典无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。许多CAS的操作是自旋的:如果操作不成功,会一直重试,直到操作成功为止。

CAS算法涉及到三个操作数:1.需要读写的内存位置(V)、2.进行比较的旧预期值(A)、3.拟写入的新值(B)

原理:当且仅当 V 的值等于 A 时,CAS通过原子方式用新值 B 来更新 V 的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。

CAS又如何保证原子性呢?
CAS是由CPU支持的原子操作,其原子性是在硬件层面进行保证的。

版本号机制

​ 使用数据库时,在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数。读取一行数据时,也会将 version 值一同读出。当数据被修改时,会把version值会加1。在向数据库提交更新的数据时,若数据库当前 version 值与用户之前读取出来的 version 值相等,就会将用户更新后的数据和 version+1 值存入数据库表中。如果两个 version 值不相等,则不断尝试更新(判断两值是否相等),直到更新成功。 这里使用了版本号作为判断数据变化的标记,实际上可以根据实际情况选用其他能够标记数据版本的字段,如时间戳等。

举例:数据库中有一张这样的表,version表示当前数据的版本号。

idnameversion
1张三1

有两个线程:线程A、线程B。它们同时取 id=1 的记录。这个时候两个线程版本号(version)都是1。

假如线程A修改了 name 并且也让version自增1,

Sql语句:update 表名 set name=‘李四’ ,version=version+1 where id=1 and version = 2 ;

提交事务,执行成功后,这时数据库表中 id=1 的这条记录为 “name=李四,versoin=2” 了。

接下来,由于线程B这家伙动作比较慢,等线程A已经提交了,然后再执行它自己的Sql语句。发现它自己之前读取的version值与现在数据库表中的version值不一致了(之前:version=1,现在:version=2),进而导致数据更新失败。

3、乐观锁的缺点
  1. ABA问题

    ​ 如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题, 它可以通过控制变量值的版本来保证 CAS 的正确性。 大部分情况下 ABA 问题不会影响程序并发的正确性, 如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。

  2. 自旋时间长开销大

    ​ 自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。 如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用, 第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源, 延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。 第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation) 而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

  3. 只能保证一个共享变量的原子操作** **CAS只对单个共享变量有效

    ​ 当操作涉及跨多个共享变量时CAS无效。 但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性, 可以把多个变量封装成对象里来进行 CAS 操作. 所以我们可以使用锁或者利用AtomicReference类把多个共享变量封装成一个共享变量来操作。

2、悲观锁

​ 顾名思义,就是很悲观。总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。悲观锁的实现,往往依靠数据库提供的锁机制。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。它指的是对数据被外界修改持保守态度(包括本系统当前的其他事务,以及来自外部系统的事务处理),在整个数据处理过程中将数据处于锁定状态。最常用的就是 select … for update,它是一种行锁,会把select出来的结果行锁住,在本事务提交或者回滚之前,不允许其他事务对这些行做update、delete、for update操作。注意:只有数据库层提供的锁机制才能真正保证数据访问的排他性,否则,即使在本系统中实现了加锁机制,也无法保证外部系统不会修改数据。Java中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。

3、两个锁的使用场景:

**乐观锁:**乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。

**悲观锁:**悲观锁适用于读比较少的情况下(多写场景),如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

六、分段锁

1、前言

​ 在并发程序中,串行操作是会降低可伸缩性,并且上下文切换也会减低性能。在锁上发生竞争时将通水导致这两种问题,使用独占锁时保护受限资源的时候,基本上是采用串行方式,每次只能有一个线程能访问它。所以对于可伸缩性来说最大的威胁就是独占锁。我们一般有三种方式降低锁的竞争程度:
1、减少锁的持有时间
2、降低锁的请求频率
3、使用带有协调机制的独占锁,这些机制允许更高的并发性。

在某些情况下我们可以将锁分解技术进一步扩展为一组独立对象上的锁进行分解,这成为分段锁。开发过程中,我们常会用到ConcurrentHashMap并发类,而当我们了解到它的底层原理后会发现,这个并发容器类的加锁机制是基于粒度更小的分段锁,分段锁也是提升多并发程序性能的重要手段之一。

2、分段锁

​ 其实说的简单一点就是:容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,各自进入各自的分段锁里,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。

​ ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。

img

3、分段锁优缺点

分段锁的优势在于保证在操作不同段 map 的时候可以并发执行,操作同段 map 的时候,进行锁的竞争和等待。这相对于直接对整个map同步synchronized是有优势的。

缺点在于分成很多段时会比较浪费内存空间(不连续,碎片化); 操作map时竞争同一个分段锁的概率非常小时,分段锁反而会造成更新等操作的长时间等待; 当某个段很大时,分段锁的性能会下降。

4、分段锁在JDK1.7和JDK1.8中的区别

JDK1.7

在jdk1.7中了,ConcurrentHashMap的底层数据结构是 Segment + HashEntry + 链表 + 红黑树。

img
size的计算

​ 我们知道ConcurrentHashMap是可以并发插入数据的,假如我们现在要计算ConcurrentHashMap的size(总元素个数)大小,一般的思路是统计每个Segment对象中的元素个数,然后对每个Segment中的元素进行累加,但是这种方式计算出来的结果可能并不准确。因为在计算后面几个Segment元素的个数时,前面已经计算过的Segment可能同时又有数据插入进来或被删除掉,破坏了预期的总数,而且计算size时最多全盘地计算3次。

  1. 第1次,先采用不加锁的方式,对每个Segment累加计算,算出size值;

  2. 第2次,也不采用加锁的方式,对每个Segment累加计算。将计算完后size与第1次计算出的size比较,如果相等。则说 明size准确无误(元素个数确实是这个数)。

  3. 如果第1次和第2次计算的size不相等,则给每个Segment进行加锁(Segment继承了ReenTrantLock,可以lock),再计算一次元素的个数;保证了一次性就可以准确地计算出元素个数;

JDK1.8

在JDK1.8中,ConcurrentHashMap放弃了Segment臃肿的设计,取而代之的是采用:数组 + 链表 + 红黑树 + CAS + Synchronized 来保证并发安全实现。ConcurrentHashMap 提供了 baseCount(记录总元素个数)、counterCells 两个辅助变量和一个 CounterCell 辅助内部类。

size()操作时,会调用sumCount(),内部就是迭代 counterCells 来统计 sum 的过程。

put()操作时,肯定会影响 size(),在 put()方法最后会调用 addCount()方法。

img

size()操作

​ 此操作也就是计算baseCount计算。JDK1.8中,用baseCount整型变量来存储总元素个数的。ConcurrentHashMap中计算baseCount有两个方法:size() 和 mappingCount()。建议使用mappingCount()。这两个方法都调用了sumCount()方法,而sumCount()里面就是一个for循环遍历数组CounterCells的成员CounterCell,通过累加来计算出总元素baseCount。

public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 :
           (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n);
}
//---------------------------------------------------------------------------------------------------
public long mappingCount() {
    long n = sumCount();
    return (n < 0L) ? 0L : n; // ignore transient negative values
}
//---------------------------------------------------------------------------------------------------
//无论是 size() 还是 mappingCount(), 计算大小的核心方法都是 sumCount()。sumCount() 的代码如下:
final long sumCount() {
    CounterCell[] as = counterCells; 
    CounterCell a;
    long sum = baseCount;
    if (as != null) {
       for (int i = 0; i < as.length; ++i) {
           if ((a = as[i]) != null)
               sum += a.value;
           }
       }
    return sum;
}
put()新节点

​ put新节点时,如果插入是新结点,则会最后执行addCount()方法尝试更新元素个数baseCount;

private final void addCount(long x, int check) {
    CounterCell[] as; long b, s;
        if ((as = counterCells) != null ||
            !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) {
            CounterCell a; long v; int m;
            boolean uncontended = true;
            if (as == null || (m = as.length - 1) < 0 ||
                (a = as[ThreadLocalRandom.getProbe() & m]) == null ||
                !(uncontended =
                  U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) {
                fullAddCount(x, uncontended);
                return;
            }
            if (check <= 1)
                return;
            s = sumCount();
        }
    //check就是结点数量,有新元素加入成功才检查是否要扩容。
    if (check >= 0) {
        Node<K,V>[] tab, nt; int n, sc;
        //s表示加入新元素后容量大小,计算已省略。
        //新容量大于当前扩容阈值并且小于最大扩容值才扩容,如果tab=null说明正在初始化,死循环等待初始化完成。
        while (s >= (long)(sc = sizeCtl) && (tab = table) != null &&
               (n = tab.length) < MAXIMUM_CAPACITY) {
            int rs = resizeStamp(n);  //@1
            //sc<0表示已经有线程在进行扩容工作
            if (sc < 0) {
                //条件1:检查是对容量n的扩容,保证sizeCtl与n是一块修改好的
                //条件2与条件3:应该是进行sc的最小值或最大值判断。
                //条件4与条件5: 确保tranfer()中的nextTable相关初始化逻辑已走完。
                if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
                    sc == rs + MAX_RESIZERS || (nt = nextTable) == null ||
                    transferIndex <= 0)
                    break;
                if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1))  //有新线程参与扩容则sizeCtl加1
                    transfer(tab, nt);
            }
            //没有线程在进行扩容,将sizeCtl的值改为(rs << RESIZE_STAMP_SHIFT) + 2),原因见下面sizeCtl值的计算分析。
            else if (U.compareAndSwapInt(this, SIZECTL, sc,
                                         (rs << RESIZE_STAMP_SHIFT) + 2))
                transfer(tab, null);
            s = sumCount();
        }
    }
}
addCount()过程

1、初始化时counterCells==null,所以直接对baseCount 做 CAS 自增操作。如果存在两个线程同时对baseCount 做 CAS 自增操作CAS失败的线程会继续执行方法体中的逻辑,并使用CounterCell数组,即变量 a 来记录元素个数的变化;,而CAS成功的线程将首先要检查CounterCell数组中的counterCells是否初始化;

2、如果CounterCell数组counterCells==null(未初始化),则调用fullAddCount()方法进行初始化,并将记录数x传递过去。

3、在fullAddCount()中,cellsBusy变量用来记录counterCells是否没在初始化或扩容的状态下,cellsBusy==0表示counterCells不在初始化或者扩容状态下。如果通过CAS设置cellsBusy(自旋锁)成功,说明没有其他线程与它抢锁,则马上初始化CounterCell数组,并回到addCount方法中,使用CAS对baseCount+1。

4、如果通过CAS设置cellsBusy字段失败的话,则继续尝试通过CAS修改baseCount字段,如果修改baseCount字段成功的话,就退出循环,否则继续死循环插入CounterCell对象,直到成功。

总结:

​ 所以在1.8中的size实现比1.7简单多,因为元素个数保存baseCount中,部分元素的变化个数保存在CounterCell数组中, 通过累加baseCount和CounterCell数组中的数量,即可得到元素的总个数;

七、无锁/偏向锁/轻量级锁/重量级锁

Java中,锁的状态可分为4种:1.无锁、2、偏向锁、3、轻量级锁、4、重量级锁

1、无锁

​ 无锁就是没有真正意义上的上锁,所有的线程还是能访问并修改同一个资源,但是通过算法控制,实现同时只有一个线程修改成功。无锁的特点就是修改操作其实一直在一个循环,线程不断循环尝试修改资源,没有冲突就修改成功,否则继续不断循环尝试。有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。

2、偏向锁

​ 因为在大多数情况下,锁总都是被同一个线程多次反复获得不存在多线程竞争,那么持有偏向锁的线程就不需要进行同步!所以就出现了偏向锁。目标就是在只有一个线程执行同步代码块时能够提高性能。引入偏向锁的目的和引入轻量级锁的目的很像,他们都是为了没有多线程竞争的前提下减少传统的重量级锁使用操作系统互斥量产生的性能消耗。但是不同是:轻量级锁无竞争的情况下使用 CAS 操作去代替使用互斥量。而偏向锁无竞争的情况下会把整个同步消除掉

偏向锁的加锁

​ 当一个线程访问同步块获取锁时, 会在锁对象的对象头和栈帧中的锁记录里存储锁偏向的线程ID, 以后该线程进入和退出同步块时不需要进行CAS操作来加锁和解锁, 需要简单的测试一下锁对象的对象头的MarkWord是否存储着指向当前线程的偏向锁(线程ID是当前线程), 如果测试成功, 表示线程已经获得了锁; 如果测试失败, 则需要再测试一下MarkWord中偏向锁的标识是否设置成1(表示当前是偏向锁), 如果没有设置, 则使用CAS竞争锁, 如果设置了, 则尝试使用CAS将锁对象的对象头的偏向锁指向当前线程。(检验对象头存储的值是否为偏向锁,避免不必要的加减锁)

偏向锁的撤销

​ 偏向锁使用了一种等到竞争出现才释放锁的机制, 所以当其他线程尝试竞争偏向锁, 持有偏向锁的线程才会释放锁. 偏向锁的撤销需要等到全局安全点(在这个时间点上没有正在执行的字节码). 首先会暂停持有偏向锁线程, 然后检查持有偏向锁的线程是否存活, 如果线程处于活动状态, 则将锁对象的对象头设置为无锁状态; 如果线程仍然活着, 则锁对象的对象头中的MarkWord和栈中的锁记录要么重新偏向其它线程要么恢复到无锁状态, 最后唤醒暂停的线程(释放偏向锁的线程)。但是对于锁竞争比较激烈的场合,偏向锁失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。(其他线程抢偏向锁,持有的线程会释放。检验后再决定持有锁的今后状态)

3、轻量级锁

​ 倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的)。轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,因为使用轻量级锁时,需要申请互斥量。另外,轻量级锁的加锁和解锁都用到CAS操作。关于轻量级锁的加锁和解锁的原理可以查看《深入理解Java虚拟机:JVM高级特性与最佳实践》第二版的13章第三节锁优化。轻量级锁能够提升程序同步性能依据是“对于绝大部分锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用 CAS 操作避免使用互斥操作的开销。但如果存在锁竞争,除了互斥量开销外,还会额外发生CAS操作,因此在有锁竞争的情况下,轻量级锁比传统的重量级锁更慢!如果锁竞争激烈,那么轻量级将很快膨胀为重量级锁!(轻量级锁没有互斥量,它的加锁、解锁使用的是CAS操作。减少性能消耗)

4、重量级锁

​ 重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也称为互斥锁。例如synchronized。

5、偏向锁、轻量锁、重量锁的对比

锁的状态优点缺点适合场景
偏向锁加锁和解锁都不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗只有一个线程访问同步块
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程,使用自旋会消耗CPU追求响应时间、同步块执行速度非常块
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间慢追求吞吐量、同步块执行时间较长

八、自旋锁

1、自旋锁

​ 自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取直到获取到锁才会退出循环

​ 它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已别的执行单元保持调用者一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。

自旋锁JDK1.6 之前其实就已经引入了,不过是默认关闭的,需要通过**–XX:+UseSpinning参数来开启**。JDK1.6及1.6之后,就改为默认开启的了。需要注意的是:自旋等待不能完全替代阻塞,因为它还是要占用处理器时间。如果锁被占用时间短,那么效果当然就很好了!反之相反!自旋等待时间必须要有限度。如果自旋超过了限定次数任然没有获得锁,就应该挂起线程。自旋次数的默认值是10次,用户可以修改**–XX:PreBlockSpin来更改**。

自旋锁存在的缺点

  1. 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
  2. 上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。

自旋锁的优点

  1. 自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
  2. 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)

2、自适应的自旋锁

​ 在 JDK1.6 中引入了自适应的自旋锁。自适应的自旋锁带来的改进就是:自旋时间不在固定了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。如果在同一个锁对象上, 自旋等待刚好成功获得锁, 并且持有锁的线程在运行中, 那么虚拟机就会认为这次自旋也是很有可能获得锁, 进而它将允许自旋等待相对更长的时间。

效果当然就很好了!反之相反!自旋等待时间必须要有限度。如果自旋超过了限定次数任然没有获得锁,就应该挂起线程。自旋次数的默认值是10次,用户可以修改**–XX:PreBlockSpin来更改**。

自旋锁存在的缺点

  1. 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高。
  2. 上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。

自旋锁的优点

  1. 自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
  2. 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)

2、自适应的自旋锁

​ 在 JDK1.6 中引入了自适应的自旋锁。自适应的自旋锁带来的改进就是:自旋时间不在固定了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。如果在同一个锁对象上, 自旋等待刚好成功获得锁, 并且持有锁的线程在运行中, 那么虚拟机就会认为这次自旋也是很有可能获得锁, 进而它将允许自旋等待相对更长的时间。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值