线程安全之锁机制

4. 线程安全之锁机制

  • 线程安全问题的主要诱因
  1. 存在共享数据【临界资源】

  2. 存在多个线程共同操作这些共享数据

  • 解决问题的方法:
  1. 同一时刻仅有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行操作

image-20210731110848979

4.1 锁的分类

同一进程

​ 面试过程时,经常会被问到各种各样的锁,如乐观锁、读写锁等等,非常繁多。这些经常问到的锁是一些锁

的名词,这些分类并不是全是指锁的状态,有的指锁的特性,有的指锁的设计,下面总结的内容是对每个锁的名词

进行一定的解释:

  • 乐观锁/悲观锁
  1. 乐观锁与悲观锁并不是特指某两种类型的锁,是人们定义出来的概念或思想,主要是指看待并发同步的角度。

  2. 乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时

候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。

  1. 乐观锁适用于多读的应用类型,这样可以提高吞吐量,在 Java 中 java.util.concurrent.atomic 包下面的原子

    变量类就是使用了乐观锁的一种实现方式 CAS(Compare and Swap 比较并交换)实现的。

  2. 乐观锁在 Java 中的使用,是无锁编程,常常采用的是 CAS 算法,典型的例子就是原子类,通过 CAS 自旋实现

原子操作的更新。

  1. 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,

这样别人想拿这个数据就会阻塞直到它拿到锁。比如 Java 里面的同步原语 synchronized 关键字的实现就是悲观

锁。

  1. 悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。

  2. 悲观锁在 Java 中的使用,就是利用各种锁。

  • 独享锁/共享锁
  1. 独享锁是指该锁一次只能被一个线程所持有。共享锁是指该锁可被多个线程所持有。

  2. 对于 Java ReentrantLock 而言,其是独享锁。但是对于 Lock 的另一个实现类 ReadWriteLock,其读锁是共

    享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写,写读,写写的过程是互斥的。

  3. 独享锁与共享锁也是通过 AQS 来实现的,通过实现不同的方法,来实现独享或者共享。

  4. 对于 Synchronized 而言,当然是独享锁。

  • 互斥锁/读写锁
  1. 上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。

  2. 互斥锁在 Java 中的具体实现就是 ReentrantLock。

  3. 读写锁在 Java 中的具体实现就是 ReadWriteLock。

  • 可重入锁

​ 从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状

态,但是当一个线程再次请求对象持有的对象锁的临界资源时,这种情况属于重入;可重入锁又名递归锁,是指在

同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。

​ 对于 Synchronized 而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。

synchronized (this){
 	System.out.println("hello");
 	synchronized (this){
 		System.out.println("world");
 	}
}

​ ReentrantLock 也是一个重入锁,使用 ReentrantLock 获取锁的时候会判断当前线程是否为获取锁的线程,

如果是则将同步的状态 +1 ,释放锁的时候则将状态 -1。只有将同步状态的次数置为 0 的时候才会最终释放锁。

  • 公平锁/非公平锁
  1. 公平锁是指多个线程按照申请锁的顺序来获取锁。(整整齐齐)

  2. 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取

锁。有可能,会造成优先级反转或者饥饿现象。(会插队)

  1. 对于 Java ReetrantLock 而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于

    吞吐量比公平锁大。

  2. 对于 Synchronized 而言,也是一种非公平锁。由于其并不像 ReentrantLock 是通过 AQS 的来实现线程调

    度,所以并没有任何办法使其变成公平锁。

  • 分段锁
  1. 分段锁其实是一种锁的设计,并不是具体的一种锁,对于 ConcurrentHashMap 而言,其并发的实现就是通

    过分段锁的形式来实现高效的并发操作。

  2. 分段锁的设计目的是细化锁的粒度(后面还有一个锁粗化),当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

  • 偏向锁/轻量级锁/重量级锁
  1. 这三种锁是指锁的状态,并且是针对 Synchronized。在 Java5 通过引入锁升级的机制来实现高效

    Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。

  2. 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。(偏心)

  3. 轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋

的形式尝试获取锁,不会阻塞,提高性能。(第三者出现)

  1. 重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的

时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让他申请的线程进入阻塞,性能降

低。(第三者永不放弃,压力山大)

在 Java 中类似的还有自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的

好处是减少线程上下文切换的消耗,缺点是循环会消耗 CPU。

不同进程

不同进程中的锁机制即分布式锁,通常有以下几种解决方案

  • 基于数据库

可以创建一张表,将其中的某个字段设置为唯一索引,当多个请求过来的时候只有新建记录成功的请求才算获取到

锁,当使用完毕删除这条记录的时候即释放锁。

存在的问题:

  1. 数据库单点问题,挂了怎么办?

  2. 不是重入锁,同一进程无法在释放锁之前再次获得锁,因为数据库中已经存在了一条记录了。

  3. 锁是非阻塞的,一旦 insert 失败则会立即返回,并不会进入阻塞队列只能下一次再次获取。

  4. 锁没有失效时间,如果那个进程解锁失败那就没有请求可以再次获取锁了。

解决方案:

  1. 数据库切换为主从,不存在单点。

  2. 在表中加入一个同步状态字段,每次获取锁的是加 1 ,释放锁的时候-1,当状态为 0 的时候就删除这条

记录,即释放锁。

  1. 非阻塞的情况可以用 while 循环来实现,循环的时候记录时间,达到 X 秒记为超时,break。

  2. 可以开启一个定时任务每隔一段时间扫描找出多少 X 秒都没有被删除的记录,主动删除这条记录

  • 基于Redis

使用 setNX(key)和setEX(timeout) 命令,只有在该 key 不存在的时候创建这个 key,就相当于获取了锁。由

于有超时时间,所以过了规定时间会自动删除,这样也可以避免死锁。使用redis实现分布式锁的核心就是

setnx+getset方式

//加锁:setnx(lock, 时间戳+超时时间) 
//解决并发:
while(jedis.setnx(lock, now+超时时间)==0{
 if(now>jedis.get(lock) && now>jedis.getset(lock, now+超时时间)){
 break;
 }else{
 Thread.sleep(300);
 } }
//执行业务代码;
jedis.del(lock);
//释放锁:jedis.del(lock);

如果在公司里落地生产环境用分布式锁的时候,一定是会用开源类库的,比如 Redis 分布式锁,一般就是用

Redisson 框架就好了,非常的简便易用。

	RLock rlock = redisson.getLock(“mylock”);
	rlock.lock();
// .....有没一种拧螺丝的感觉
    Rlock.unlock();
  • 基于 ZK

临时节点解决

4.2 Synchronized加锁

4.2.1 synchronized 关键字的理解

​ Synchronized 关键字底层原理属于 JVM 层面。

​ 当多个线程同时访问同一个数据时,很容易出现问题。为了避免这种情况出现,我们要保证线程同步互斥,

就是指并发执行的多个线程,在同一时间内只允许一个线程访问共享数据。 Java 中可以使用 synchronized 关键

字来取得一个对象的同步锁。

​ Synchronized 关键字解决的是多个线程之间访问资源的同步性,synchronized 关键字可以保证被它修饰的

方法或者代码块在任意时刻只能有一个线程执行。

​ 另外,在 Java 早期版本中,synchronized 属于重量级锁效率低下,因为监视器锁(monitor)是依赖于底层

的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个

线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的

转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。

​ Java 6 之后从 JVM 层面对 synchronized 较大优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、

轻量级锁等技术来减少锁操作的开销。

​ 锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的

激烈而逐渐升级。注意锁可以升级不可降级,这种策略是为了提高获得锁和释放锁的效率。

4.2.2 synchronized 关键字的使用

​ synchronized 它可以把任意一个非 NULL 的对象当作锁。他属于独占式的悲观锁,同时属于可重入锁。

​ 一个对象里面如果有多个 synchronized 方法,某一个时刻内,只要一个线程去调用其中的一个synchronized

方法了,其它的线程都只能等待,换句话说,某一个时刻内,只能有唯一一个线程去访问这些 synchronized 方

法。

​ Synchronized 根据获取的锁分类,可以分为对象锁和类锁

  • 获取对象锁的两种方法

1、 同步代码块:synchronized(this、类实例对象)锁住的是小括号中的实例对象

2、 同步非静态方法 synchronized method,锁是对当前对象的实例对象

  • 获取类锁的两种方法

1、 同步代码块 synchronized(类名.class),锁住的是小括号中的的类对象(Class 对象)

2、 同步静态方法 synchronized static method,锁的是当前对象的类对象(Class 对象)

  • 对象锁和类锁的总结

1、 有线程访问对象的同步代码块时,另外的线程可以访问该对象的非同步代码块

2、 若锁住的是同一个对象,一个线程在访问对象的同步代码块(或者同步方法)时,另外一个访问对象的同步

代码块(或者同步方法)的线程会被阻塞

3、 若锁住的是同一个对象,一个想成在访问对象的同步代码块时,另外一个访问对象的同步方法的线程会被

阻塞,反之亦然;

4、 同一个类的不同对象的对象锁互相不干扰

5、 类锁也是一中特殊的兑现锁,因此表现和上述描述一致。而由于一个类只能有一把对象锁,所以同一个类

的不同对象使用类锁将会是同步的

6、 类锁和对象锁互相不干扰

4.2.3 synchronized关键字原理

4.2.3.1 Java 对象在内存中的布局

在 JVM 中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。

image-20210731133704959

实例数据:存放类的属性数据信息,包括父类的属性信息;

对齐填充:由于虚拟机要求对象起始地址必须是 8 字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对

齐;

**对象头:**Java 对象头一般占有 2 个机器码(在 32 位虚拟机中,1 个机器码等于 4 字节,也就是 32bit,在 64 位

虚拟机中,1 个机器码是 8 个字节,也就是 64bit),但是如果对象是数组类型,则需要 3 个机器码,因为 JVM 虚

拟机可以通过 Java 对象的元数据信息确定 Java 对象的大小,但是无法从数组的元数据来确认数组的大小,所以用

一块来记录数组长度。

​ Synchronized 用的锁就是存在 Java 对象头里的,那么什么是 Java 对象头呢?Hotspot 虚拟机的对象头主要

包括两部分数据:Mark Word(标记字段)、Class Pointer(类型指针)。其中 Class Pointer 是对象指向它的类

元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word 用于存储对象自身的运行时数

据,它是实现轻量级锁和偏向锁的关键。

image-20210731134232819

image-20210731134215042

image-20210731133759727

对象头的最后两位存储了锁的标志位,01 是初始状态,未加锁,其对象头里存储的是对象本身的哈希码,随着锁

级别的不同,对象头里会存储不同的内容。偏向锁存储的是当前占用此对象的线程 ID;而轻量级则存储指向线程

栈中锁记录的指针。从这里我们可以看到,“锁”这个东西,可能是个锁记录+对象头里的引用指针(判断线程是否

拥有锁时将线程的锁记录地址和对象头里的指针地址比较),也可能是对象头里的线程 ID(判断线程是否拥有锁时

将线程的 ID 和对象头里存储的线程 ID 比较)

4.2.3.2 synchronized 同步语句块的情况
public class SynchronizedDemo { 
	public void method() { 
	synchronized (this) {
 			System.out.println("synchronized 代码块");
		 }
	}
}

通过 JDK 自带的 javap 命令查看 SynchronizedDemo 类的相关字节码信息

D:\workspace\javabasic\javabasic\src>javac com /javabasic/thread/ SynchronizedDemo.java

D:\workspace\javabasic\javabasic\src>javap -verbose com/javabasic/thread/ SynchronizedDemo.class

image-20210731134110194

从上面我们可以看出:

image-20210731134127592

synchronized 同步语句块的实现使用的是 monitorenter monitorexit 指令,其中 monitorenter 指令

**指向同步代码块的开始位置,monitorexit **指令则指明同步代码块的结束位置。

当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor 对象存在于每个 Java 对象的对象头

中,synchronized 锁便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因) 的持有权.当计

数器为 0 则可以成功获取,获取后将锁计数器设为 1 也就是加 1。相应的在执行 monitorexit 指令后,将锁计数器

设为 0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

4.2.3.3 synchronized 修饰方法的的情况
public class SynchronizedDemo2 {
 public synchronized void method() { 
		System.out.println("synchronized 方法"); 
	}
}

image-20210731134531387

​ synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的却是

ACCSYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM通过该ACCSYNCHRONIZED 访问标

志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

​ 当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线

程将先获取 monitor,获取成功之后才能执行方法体,方法执行完后再释放 monitor。在方法执行期间,其他任

何线程都无法再获得同一个 monitor 对象。

​ 两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指

令的执行是 JVM 通过调用操作系统的互斥原语 mutex 来实现,被阻塞的线程会被挂起、等待重新调度,会导

致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

4.2.4 synchronized关键字和 volatile 关键字的区别

1、 volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized

则是锁定当前变量,只有对当前线程可以访问该变量,其他线程被阻塞住直到该线程完成变量操作为止

2、 volatile 仅适用在变量级别;synchronized 则可以使用在变量、方法和类级别

3、 volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化

4、 volatile 关键字是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 关键字要好。但是

volatile关键字只能用于变量而synchronized 关键字可以修饰方法以及代码块。synchronized 关键字在

JavaSE1.6 之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种

优化之后执行效率有了显著提升,实际开发中使用 synchronized 关键字的场景还是更多一些

5、 多线程访问 volatile 关键字不会发生阻塞,而 synchronized 关键字可能会发生阻塞

6、 volatile **关键字能保证数据的可见性,但不能保证数据的原子性。**synchronized 关键字两者都能保证。

7、 volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间

访问资源的同步性。

4.3 JDK1.6 之后的底层优化

​ 在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操

作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线

程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转

换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在

Java 6 之 后 Java 官方对从 JVM 层面对 synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不

错了。JDK1.6 对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等

技术来减少锁操作的开销。

​ JDK1.6 对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技

术来减少锁操作的开销。

​ 锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的

激烈而逐渐升级称之为锁膨胀,锁膨胀的方向是无锁》偏向锁》轻量级锁》重量级锁。注意锁可以升级不可降级,

这种策略是为了提高获得锁和释放锁的效率。

4.3.1 自旋锁和适应性自旋锁

百度百科对自旋锁的解释

何谓自旋锁?它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对

某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最

多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者

只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在

那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。

一个舔狗(自旋锁)和一个非舔狗(互斥锁)

4.3.1.1 自旋锁

许多情况下,共享数据的锁定状态持续时间较短,切换线程浪费资源。因此通过看似无意义的循环反而能提升

锁的性能。 但是自旋必须要有一定的条件控制,jdk1.6,默认启用,默认情况下自旋的次数是 10 次, 可以通过

-XX:PreBlockSpin=10 来修改。

  1. 其他未获得锁的线程可以通过执行忙循环(自旋)等待锁的释放,而不是把该线程给阻塞【是会消耗 cpu 的】

  2. 自旋锁在 JDK1.6 之前其实就已经引入了,不过是默认关闭的,需要通过–XX:+UseSpinning 参数来开启。

JDK1.6 及 1.6 之后,就改为默认开启的了。

  1. 缺点:若锁被其他线程长时间占用,会带来许多性能上的开销。
4.3.1.2 自适应自旋锁(更人性)

在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据:

  1. 同一个锁上一次自旋的时间。

  2. 拥有锁线程的状态来决定。

  3. 目的:最大的提高处理器资源利用率。

举例:

​ 如果在同一个锁对象上,刚刚自旋等待成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为

这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获

得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费 cpu 资源。

4.3.2 锁消除

​ 锁消除理解起来很简单,它指的就是虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞

争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁的时间。

​ 锁消除是一种更彻底的优化。JIT 编译【just-in-time compilation 狭义来说是当某段代码即将第一次被执行时

进行编译,因而叫“即时编译”。JIT 编译是动态编译的一种特例。JIT 编译一词后来被泛化,时常与动态编译等价】

时,对运行上下文进行扫描,取出不可能存在竞争的锁。

public class StringBufferWithoutSync {
 	public void add(String str1, String str2) {
 		//StringBuffer 是线程安全,由于 sb 只会在 append 方法中使用,不可能被其他线程引用
 		//因此 sb 属于不可能共享的资源,JVM 会自动消除内部的锁
 		StringBuffer sb = new StringBuffer();
 		sb.append(str1).append(str2);
	 }
 public static void main(String[] args) {
	 StringBufferWithoutSync withoutSync = new StringBufferWithoutSync();
 	for (int i = 0; i < 1000; i++) {
 	withoutSync.add("aaa", "bbb");
		 }
	 }	
}

4.3.3 锁粗化

​ 原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,—直在共享数据的实际作

用域才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也能尽快拿到

锁。

​ 大部分情况下,上面的原则都是没有问题的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,

那么会带来很多不必要的性能消耗。因此通过加锁的范围,避免反复加锁和解锁,就是锁粗化。

public class CoarseSync {
 	public static String copyString100Times(String target){
 		 int i = 0;
		 StringBuffer sb = new StringBuffer();
		 while (i<100){
			 sb.append(target);
             i++;
		 }
 			return sb.toString();
	 } 
}

4.3.4 偏向锁

引入偏向锁的目的和引入轻量级锁的目的很像,他们都是为了没有多线程竞争的前提下,减少传统的重量级锁

使用操作系统互斥量产生的性能消耗。但是不同是:轻量级锁在无竞争的情况下使用 CAS 操作去代替使用互斥

量。而偏向锁在无竞争的情况下会把整个同步都消除掉

​ 偏向锁的“偏”就是偏心的偏,它的意思是会偏向于第一个获得它的线程,如果在接下来的执行中,该锁没有被

其他线程获取,那么持有偏向锁的线程就不需要进行同步!【关于偏向锁的原理可以查看《深入理解 Java 虚拟

机:JVM 高级特性与最佳实践》第二版的 13 章第三节锁优化。】

​ 偏向锁的核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时 Mark Word 的结构也变为偏向锁

结构,当线程再次请求锁时,无需再做任何同步操作,即获得锁的过程只检查 Mark Word 的锁标记为偏向锁以及

当前线程 ID 等于 Mark Word 的 ThreadID 即可,这样就省去了大量有关锁申请的操作。

4.3.5 轻量级锁

轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统

互斥量产生的性能消耗,因为使用轻量级锁时,不需要申请互斥量。另外,轻量级锁的加锁和解锁都用到了 CAS

操作。

轻量级锁能够提升程序同步性能的依据是对于绝大部分锁,在整个同步周期内都是不存在竞争的,这是一个

经验数据。如果没有竞争,轻量级锁使用 CAS 操作避免了使用互斥操作的开销。但如果存在锁竞争,除了互斥量

开销外,还会额外发生 CAS 操作,因此在有锁竞争的情况下,轻量级锁比传统的重量级锁更慢!如果锁竞争激

烈,那么轻量级将很快膨胀为重量级锁!

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,有当第二个线程加入锁争用的使用,

偏向锁就升级为轻量级锁。

适用场景:线程交替执行同步块

若存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁

image-20210731145109315

4.4 Lock同步锁

用于解决多线程安全问题的方式:

  • synchronized:隐式锁

    • 同步代码块
    • 同步方法
  • Lock 显示锁

    • 同步锁 Lock

需要通过 lock() 方法手动上锁,通过 unlock() 方法手动进行释放锁

​ 在 Java 5.0 之前,协调共享对象的访问时可以使用的机制只有 synchronized 和 volatile 。Java 5.0 后增加了

一些新的机制,但并不是一种替代内置锁的方法,而是当内置锁不适用时,作为一种可选择的高级功能。

ReentrantLock 实现了 Lock 接口,并提供了与 synchronized 相同的互斥性和内存可见性。但相较于

synchronized 提供了更高的处理锁的灵活性。提供了更高的处理锁的灵活性。

4.4.1 Lock接口的主要方法

  1. void lock(): 执行此方法时, 如果锁处于空闲状态, 当前线程将获取到锁. 相反, 如果锁已经 被其他线程持有,

将禁用当前线程, 直到当前线程获取到锁.

  1. boolean tryLock():如果锁可用, 则获取锁, 并立即返回 true, 否则返回 false. 该方法和 lock()的区别在于,

tryLock()只是"试图"获取锁, 如果锁不可用, 不会导致当前线程被禁用, 当前线程仍然继续往下执行代码. 而

lock()方法则是一定要获取到锁, 如果锁不可用, 就一 直等待, 在未获得锁之前,当前线程并不继续向下执行.

  1. void unlock():执行此方法时, 当前线程将释放持有的锁. 锁只能由持有者释放, 如果线程 并不持有锁, 却执行

该方法, 可能导致异常的发生.

  1. Condition newCondition():条件对象,获取等待通知组件。该组件和当前的锁绑定, 当前线程只有获取了锁,

才能调用该组件的 await()方法,而调用后,当前线程将缩放锁。

  1. getHoldCount() :查询当前线程保持此锁的次数,也就是执行此线程执行 lock 方法的次 数。

  2. getQueueLength():返回正等待获取此锁的线程估计数,比如启动 10 个线程,1 个 线程获得锁,此时返

回的是 9

  1. getWaitQueueLength:(Condition condition)返回等待与此锁相关的给定条件的线 程估计数。比如 10 个线程,

用同一个 condition 对象,并且此时这 10 个线程都执行了 condition 对象的 await 方法,那么此时执行此方法返

回 10

  1. hasWaiters(Condition condition) : 查 询 是 否 有 线 程 等 待 与 此 锁 有 关 的 给 定 条 件

(condition),对于指定 contidion 对象,有多少线程执行了 condition.await 方法

  1. hasQueuedThread(Thread thread):查询给定线程是否等待获取此锁

  2. hasQueuedThreads():是否有线程等待此锁

  3. isFair():该锁是否公平锁

  4. isHeldByCurrentThread(): 当前线程是否保持锁锁定,线程的执行 lock 方法的前后分 别是 false 和 true

  5. isLock():此锁是否有任意线程占用

  6. lockInterruptibly():如果当前线程未被中断,获取锁

  7. tryLock():尝试获得锁,仅在调用时锁未被线程占用,获得锁

  8. tryLock(long timeout TimeUnit unit):如果锁在给定等待时间内没有被另一个线程保持, 则获取该锁。

  • tryLock lock lockInterruptibly 的区别
  1. tryLock 能获得锁就返回 true,不能就立即返回 false,tryLock(long timeout,TimeUnit unit),可以增加时间

    限制,如果超过该时间段还没获得锁,返回 false

  2. lock 能获得锁就返回 true,不能的话一直等待获得锁

  3. lock 和 lockInterruptibly,如果两个线程分别执行这两个方法,但此时中断这两个线程, lock 不会抛出异

    常,而 lockInterruptibly 会抛出异常。

4.4.2 AQS 框架

AbstractQueuedSynchronizer 类如其名,抽象的队列式的同步器,AQS 定义了一套多线程访问 共享资源的同步

器框架,许多同步类实现都依赖于它,如常用的 ReentrantLock/Semaphore/CountDownLatch。

https://segmentfault.com/a/1190000017372067

​ AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共

享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机

制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

​ CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在

结点之间的关联关系)。AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现

锁的分配。

AQS(AbstractQueuedSynchronizer)原理图:

image-20210731220214152

AQS 维护了一个 volatile int state(代表共享资源)和一个 FIFO 线程等待队列(多线程争用资源被阻塞时会进

入此队列)来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。这里

volatile 是核心关键词,实现了可见性。state 的访问方式有三种:

//返回同步状态的当前值
protected final int getState() {
return state;
}
// 设置同步状态的值
protected final void setState(int newState) {
state = newState;
}
//原子地(CAS 操作)将同步状态值设置为给定值 update 如果当前同步状态的值等于 expect(期望值)
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
  • AQS 定义两种资源共享方式

Exclusive(独占):只有一个线程能执行,如 ReentrantLock。

Share(共享):多个线程可同时执行,如 Semaphore/CountDownLatch。Semaphore、CountDownLatCh、

CyclicBarrier、ReentrantReadWriteLock 也就是读写锁允许多个线程同时对某一资源进行读。

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放

方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在顶层实现好了。

4.4.3 ReentrantLock

​ ReentrantLock 位于 juc 包下的可重入锁,是基于 AQS 来实现的。能够实现比 synchronized 更细粒度的控

制,性能上未必比 synchronized 高。

​ ReentantLock 实现了 Lock 接口并实现了接口中定义的方法,他是一种可重入锁,除了能完成 synchronized

所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等 避免多线程死锁的方法。

​ 构造方法:ReentrantLock lock = new ReentrantLock(Boolean ifFair)

​ 当 isFair=true 时,是一个公平锁,获取锁的顺序按照先后调用 lock 方法的顺序。因此倾向于将锁赋予等待时

间最久的线程

​ 当 isFair=false 时,是一个非公平锁,抢占的顺序不一定。注意 synchronized 是非公平锁

4.4.4 ReadWriteLock 读写锁

为了提高性能,Java 提供了读写锁,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的

情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。读写锁分为读锁和写锁,多个读锁不互斥,读锁与写

锁互斥,这是由 jvm 自己控制的,你只要上好相应的锁即可。

  • 读锁:如果你的代码只读数据,可以很多人同时读,但不能同时写,那就上读锁

  • 写锁:如果你的代码修改数据,只能有一个人在写,且不能同时读取,那就上写锁。

  • ReadWriteLock 维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁

    可以由多个 reader 线程同时保持。写入锁是独占的。

  • ReadWriteLock 读取操作通常不会改变共享资源,但执行写入操作时,必须独占方式来获取锁。对于读取操

    作占多数的数据结构。

  • ReadWriteLock 能提供比独占锁更高的并发性。而对于只读的数据结构,其中包含的不变性可以完全不需要

    考虑加锁操作。

读写需要互斥、写写需要互斥、读读不需要互斥

public class TestReadWriteLock {
    public static void main(String[] args) {



        ReadWriteLockDemo rw = new ReadWriteLockDemo();
        new Thread(new Runnable() {
            @Override
            public void run() {
                rw.set((int) (Math.random() * 101));
            }
        }, "Write:").start();


        for (int i = 0; i < 100; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    rw.get();
                }
            }).start();
        }
    }
}

class ReadWriteLockDemo {
    private int number = 0;
    private ReadWriteLock lock = new ReentrantReadWriteLock(false);

    //读
    public void get() {
        lock.readLock().lock(); //上锁
        try {
            System.out.println(Thread.currentThread().getName() + " : " + number);
        } finally {
            lock.readLock().unlock(); //释放锁
        }
    }

    //写
    public void set(int number) {
        lock.writeLock().lock();
        try {
            System.out.println(Thread.currentThread().getName());
            this.number = number;
        } finally {
            lock.writeLock().unlock();
        }
    }
}

/*
输出结果:
Write:
Thread-0 : 86
Thread-1 : 86
Thread-2 : 86
Thread-3 : 86
Thread-5 : 86
Thread-6 : 86
Thread-9 : 86
Thread-4 : 86
Thread-7 : 86
Thread-8 : 86
*/

4.4.5 Condition 控制线程通信

Condition 接口描述了可能会与锁有关联的条件变量。这些变量在用法上与使用 Object.wait 访问的隐式监视器

类似,但提供了更强大的功能。需要特别指出的是,单个 Lock 可能与多个 Condition 对象关联。为了避免兼容性

问题,Condition 方法的名称与对应的 Object 版本中的不同。

在 Condition 对象中,与 wait、notify 和 notifyAll 方法对应的分别是 await、signal 和 signalAll。Condition 实

例实质上被绑定到一个锁上。要为特定 Lock 实例获得 Condition 实例,请使用其 newCondition() 方法。

4.5 synchronized Lock 的比较

  • 二者共同点
  1. 都是用来协调多线程对共享对象、变量的访问

  2. 都是可重入锁,同一线程可以多次获得同一个锁

  3. 都保证了可见性和互斥性

  • 二者不同点
  1. ReentrantLock 显示的获得、释放锁,synchronized 隐式获得释放锁

  2. ReentrantLock 相比 synchronized 的优势是可响应、可轮回、可中断(Lock 可以让等待锁的线程响应中

    断)、公平锁、多个锁,synchronized 等待的线程会一直等待下去,不能够响应中断

  3. ReentrantLock 是 API 级别的,使用的同步非阻塞,采用的是乐观并发策略具体是使用的 Unsafe.park()实现

    的,synchronized 是 JVM 级别的是同步阻塞,使用的是悲观并发策略,具体是 sync 操作 MarkWord 实现的

  4. ReentrantLock 可以实现公平锁 Lock lock=new ReentrantLock(true);//true 公平锁 false 非公平锁

  • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁

  • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的

  1. ReentrantLock 通过 Condition 可以绑定多个条件

  2. synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而 Lock 在发生异常

    时,如果没有主动通过 unLock()去释放锁,则很可能造成死锁现象, 因此使用 Lock 时需要在 finally 块中释

    放锁。

  3. 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。

  4. Lock 可以提高多个线程进行读操作的效率,既就是实现读写锁等

4.6 线程锁的经典应用

4.6.1 线程按序交替举例

编写一个程序,开启 3 个线程,这三个线程的 ID 分别为 A、B、C,每个线程将自己的 ID 在屏幕上打印 10 遍,

要求输出的结果必须按顺序显示。如:ABCABCABC„„依次递归

public class TestABCAltermate {
    public static void main(String[] args) {


        // 共享资源控制打印顺序
        AlternateDemo ad = new AlternateDemo();

        new Thread(()->{
            for (int i = 0; i < 10 ; i++) {
                ad.printA(i);
            }
        }, "A").start();

        new Thread(()->{
            for (int i = 0; i < 10 ; i++) {
                ad.printB(i);
            }
        }, "B").start();

        new Thread(()->{
            for (int i = 0; i < 10 ; i++) {
                ad.printC(i);
                System.out.println("---------循环一次-----------");
            }
        }, "C").start();

    }
}

class  AlternateDemo{
    // 记录当前线程的标识
    private  String currentThread = "A";
    //锁,控制谁获得了锁,谁有权限执行
    private Lock lock = new ReentrantLock();
    // 控制条件
    private Condition conditionA = lock.newCondition();
    private Condition conditionB = lock.newCondition();
    private Condition conditionC = lock.newCondition();

    public void printA(int i) {
        lock.lock();
        try{
            if (!"A".equals(currentThread)){
                conditionA.await();
            }
            System.out.println(Thread.currentThread().getName()+"第"+i+"次打印 ");
            // 通知B线程可以打印了
            currentThread = "B";

            // 手动唤醒B,让B打印
            conditionB.signal();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            lock.unlock();
        }

    }

    public void printB(int i) {
            lock.lock();
        try{
            if (!"B".equals(currentThread)){
                conditionB.await();
            }
            System.out.println(Thread.currentThread().getName()+"第"+i+"次打印 ");
            // 通知B线程可以打印了
            currentThread = "C";
            conditionC.signal();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }

    public void printC(int i) {
        lock.lock();
        try{
            if (!"C".equals(currentThread)){
                conditionC.await();
            }
            System.out.println(Thread.currentThread().getName()+"第"+i+"次打印 ");
            // 通知A线程可以打印了
            currentThread = "A";
            conditionA.signal();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }
}
/*
运行结果:
A第0次打印 
B第0次打印 
C第0次打印 
---------循环一次-----------
A第1次打印 
B第1次打印 
C第1次打印 
---------循环一次-----------
A第2次打印 
B第2次打印 
C第2次打印 
---------循环一次-----------
A第3次打印 
B第3次打印 
C第3次打印 
---------循环一次-----------
A第4次打印 
B第4次打印 
C第4次打印 
---------循环一次-----------
A第5次打印 
B第5次打印 
C第5次打印 
---------循环一次-----------
A第6次打印 
B第6次打印 
C第6次打印 
---------循环一次-----------
A第7次打印 
B第7次打印 
C第7次打印 
---------循环一次-----------
A第8次打印 
B第8次打印 
C第8次打印 
---------循环一次-----------
A第9次打印 
B第9次打印 
C第9次打印 
---------循环一次-----------

*/

4.6.2 生产者和消费者案例

4.6.2.1 Synchronized wait\ notifyAll 机制解决方式
  • wait:必须使用在循环中,才能避免虚假唤醒问题
/**
 * 生产者是线程runnable
 * 消费者是线程runnable
 * 生产者和消费者有共享数据,加锁;提供生产和消费两种动作
 * 主线程控制业务流程
 *
 */
public class TestProducerAndConsumer {

    public static void main(String[] args) {
        Clerk clerk = new Clerk();

        Producer producer = new Producer(clerk);
        Consumer consumer = new Consumer(clerk);


        // 启动线程
        new Thread(producer,"生产者").start();
        new Thread(consumer,"消费者").start();
    }
}

class Clerk {
    private  int product = 0;

    // 进货
    public synchronized void buy(){
        while (product >= 1){
            System.out.println("仓库有货,等待消费...");
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 进货
        product ++;
        System.out.println(Thread.currentThread().getName()+"::"+product);
        this.notifyAll();
    }

    // 卖货
    public  synchronized  void sale(){
        while (product<=0){
            System.out.println("仓库无货,请等待进货...");
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 消费
        product --;
        System.out.println(Thread.currentThread().getName()+"::"+product);
        this.notifyAll();
    }

}

// 生产者
class Producer implements Runnable{
    private  Clerk clerk;

    public Producer(Clerk clerk){
        this.clerk = clerk;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            clerk.buy();
        }
    }
}


// 消费者
class Consumer implements Runnable{
    private  Clerk clerk;

    public Consumer(Clerk clerk){
        this.clerk = clerk;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            clerk.sale();
        }
    }
}


4.6.2.2 同步锁 ReentrantLock Condition 的方式解决

使用 lock 方式不存在虚假唤醒的问题

/**
 * 生产者是线程runnable
 * 消费者是线程runnable
 * 生产者和消费者有共享数据,加锁;提供生产和消费两种动作
 * 主线程控制业务流程
 *
 */
public class TestProducerAndConsumer2 {

    public static void main(String[] args) {
        Clerk2 clerk = new Clerk2();

        Producer2 producer2 = new Producer2(clerk);
        Consumer2 consumer2 = new Consumer2(clerk);


        // 启动线程
        new Thread(producer2,"生产者").start();
        new Thread(consumer2,"消费者").start();
    }
}

class Clerk2 {
    private  int product = 0;
    private Lock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();

    // 进货
    public  void buy(){
        lock.lock();
        if (product >= 1){
            System.out.println("仓库有货,等待消费...");
            try {
                condition.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                lock.unlock();
            }
        }
        // 进货
        product ++;
        System.out.println(Thread.currentThread().getName()+"::"+product);
        condition.signalAll();
    }

    // 卖货
    public    void sale(){
        lock.lock();
        if (product<=0){
            System.out.println("仓库无货,请等待进货...");
            try {
                condition.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                lock.unlock();
            }
        }
        // 消费
        product --;
        System.out.println(Thread.currentThread().getName()+"::"+product);
       condition.signalAll();
    }

}

// 生产者
class Producer2 implements Runnable{
    private  Clerk2 clerk;

    public Producer2(Clerk2 clerk){
        this.clerk = clerk;
    }

    @Override
    public void run() {
        for (int i = 0; i < 2; i++) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            clerk.buy();
        }
    }
}


// 消费者
class Consumer2 implements Runnable{
    private  Clerk2 clerk;

    public Consumer2(Clerk2 clerk){
        this.clerk = clerk;
    }

    @Override
    public void run() {
        for (int i = 0; i < 2; i++) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            clerk.sale();
        }
    }
}


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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值