2.3 多线程

本文主要源自 JavaGuide 地址:https://github.com/Snailclimb/JavaGuide 作者:SnailClimb
仅供个人复习使用

2.3.1 说说 sleep() 方法和 wait() 方法的区别?

  • 两者最主要的区别在于:sleep 方法没有释放锁,而 wait 方法释放了锁
  • 每个对象都有一个锁来控制同步访问,Synchronized 关键字可以和对象的锁交互,来实现同步方法或同步块。
    • sleep()方法会让正在执行的线程主动让出CPU,在指定时间后CPU再回到该线程继续往下执行(注意:sleep方法只让出了CPU,而并不会释放同步资源锁!!!);
    • wait()方法则是指让当前线程主动释放同步资源锁,以便其他正在等待该资源的线程得到该资源进而运行,只有调用了notify()方法,之前调用wait()的线程才会解除wait状态,可以去参与竞争同步资源锁,进而得到执行。(注意:notify的作用相当于叫醒睡着的人,而并不会给他分配任务,就是说notify只是让之前调用wait的线程有权利重新参与线程的调度);
  • sleep() 方法可以在任何地方使用;wait() 方法则只能在同步方法或同步块中使用;(有点像循环体中用 break 退出)
  • sleep() 是线程线程类(Thread)的方法,调用会暂停此线程指定的时间,但监控依然保持,不会释放对象锁,到时间自动恢复;wait() 是 Object 的方法,调用会放弃对象锁,进入等待队列,待调用 notify()/notifyAll() 唤醒指定的线程或者所有线程,才会进入锁池,不再次获得对象锁才会进入运行状态;

补充:释放锁的两种方式
(1) 程序自然离开监视器的范围,即离开synchronized关键字管辖的代码范围
(2) 在synchronized关键字管辖的代码内部调用监视器对象的wait()方法。


2.3.2 为什么我们调用 start()方法时会执行 run() 方法,为什么我们不能直接调用 run() 方法?

new 一个 Thread ,线程进入了新建状态;调用 start() 方法,会启动一个线程并使线程进入就绪状态,当分配到时间片后就可以开始运行了。start() 方法会执行创建线程的相应准备工作,然后自动执行 run() 方法的内容。用 start() 方法来启动线程,真正实现了多线程运行,这时无需等待 run 方法体代码执行完毕而直接继续执行后面的代码。

直接执行 run() 方法,会把 run() 方法当成一个 main 线程下的普通方法去执行,如果直接调用 run 方法,程序中依然只有主线程这一个线程,其程序执行路径还是只有一条,还是要顺序执行,还是要等待 run 方法体执行完毕后才可继续执行下面的代码,这样就没有达到多线程的目的。

总结:如果先调用 start 方法,就可以启动一个线程;而如果先调用 run 方法,run 方法只是 Thread 的一个普通方法调用,还是在主线程里顺序执行,并不是多线程。


2.3.3 synchronized 关键字

1. 说一说自己对于 synchronized 关键字的了解

synchronized 解决的是多个线程之间访问资源的同步性,它可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

另外,在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器(monitor)的锁是依赖于底层的操作系统的 Mutex Lock 来实现的, Java 的线程是映射到操作系统的原生线程上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程切换需要从用户态切换到内核态,时间成本较高,这就是早期效率低的原因。

在 Java 6 之后, Java 官方从 JVM 层面对 synchronized 进行较大优化,如自旋锁、适应性自旋锁、锁销除、偏向锁、轻量级锁等技术来减少锁操作的开销。

推荐阅读:
大白话聊聊synchronized、CAS底层原理、Lock锁和锁升级原理
Synchronized解析——如果你愿意一层一层剥开我的心


2. synchronized关键字最主要的两种使用方式

在这里插入图片描述

总结:
synchronized 加到 静态方法 和 synchronized(class) 代码块上都是给 Class 类上锁。
synchronized 加到实例方法(非静态方法)和 synchronized(this) 代码块上是给 实例对象 上锁。

所以如果一个对象在两个线程中分别调用一个静态同步方法和一个非静态同步方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类对象的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象的锁


3. 说说自己是怎么使用 synchronized 关键字,在项目中用到了吗?

下面以一个常见的面试题来讲解 synchronized 关键字的具体使用。

面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重校验锁方式实现单例模式的原理呗!”

双重校验锁实现单例模式(线程安全):

public class Singleton {  
    private volatile static Singleton singleton;  
    private Singleton (){}  
    public static Singleton getSingleton() {  
    	//先判断对象是否已经实例过,没有实例过才进入加锁代码
	    if (singleton == null) {  
	    	//类对象加锁
	        synchronized (Singleton.class) {  
		        if (singleton == null) {  
		            singleton = new Singleton();  
		        }  
	        }  
	    }  
	    return singleton;  
    }  
}

说明如下:

  • 第一次判断singleton是否为null

    第一次判断是在 synchronized 同步代码块外进行判断,由于单例模式只会创建一个实例,并通过 getSingleton 方法返回Singleton 对象,所以,第一次判断,是为了在 Singleton 对象已经创建的情况下,避免进入同步代码块,提升效率。

  • 第二次判断singleton是否为null

    第二次判断是为了避免以下情况的发生。

    (1) 假设:线程 A 已经经过第一次判断,判断 singleton=null,准备进入同步代码块.
    (2) 此时线程 B 获得时间片,由于线程 A 并没有创建实例,所以,判断 singleton 仍然 =null,所以线程B创建了实例 singleton。
    (3) 此时,线程 A 再次获得时间片,由于刚刚经过第一次判断 singleton=null (不会重复判断),进入同步代码块,这个时候,我们如果不加入第二次判断的话,那么线程 A 又会创造一个实例 singleton ,就不满足我们的单例模式的要求,所以第二次判断是很有必要的。

  • 为什么要加Volatile关键字

    首先,我们需要知道Volatile可以保证可见性和原子性,同时保证JVM对指令不会进行重排序。

    uniqueInstance = new Singleton() 为例,这段代码其实是分为三步执行:
    (1)为 Singleton 对象分配内存空间
    (2)初始化 Singleton 对象
    (3)将 Singleton 对象的内存地址赋值给引用变量 uniqueInstance

    但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1 -> 3 -> 2。指令重排在单线程下不会出现问题,但在多线程环境下可能会导致一个线程获得还没有初始化的实例对象

    例如,线程 A 执行了 1 和 3,此时线程 B 调用 getSingleton 方法后发现 uniqueInstance 不为空,因此直接返回已创建的对象,但此时 uniqueInstance 还未被初始化。因此,使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。


4. 讲一下 synchronized 关键字的底层原理

① synchronized 同步代码块的情况

在这里插入图片描述
synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中,monitorenter 指令指向同步代码块的开始位置,monitorexit 指令指向同步代码块的结束位置

当执行 monitorenter 指令时,线程试图获取锁,也就是获取 monitor 的持有权(每个 Java 对象的对象头都有指向 monitor 对象的指针, synchronized 锁便是通过这种方式获取锁的,也是为什么 Java 中任意对象可以作为锁的原因)。当计数器为 0 则表示可以成功获取,获取后将锁计数器设为 1 。相应的在执行 monitorexit 指令后,将锁计数器设为 0 ,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。


② synchronized 修饰方法的情况

在这里插入图片描述
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取而代之的是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标识来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

总结:
synchronized 修饰 代码块(实例对象和类对象) 时,使用的是 monitorenter 指令和 monitorexit 指令;
synchronized 修饰 方法 (实例方法和静态方法) 时,使用的是 ACC_SYNCHRONIZED 标识,指明了该方法是一个同步方法;


5. 说一说 JDK1.6 之后的 synchronized 关键字底层做了哪些优化,可以详细介绍一下这些优化吗?

JDK1.6 中对锁的实现引入了大量的优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁销除、锁粗化等技术来减少锁操作的开销。

锁主要存在4种状态:依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞争的激烈而逐渐升级。 注意,锁可以升级但不可以降级,这种策略是为了提高获得锁和释放锁的效率。

在这里插入图片描述

① 偏向锁

偏向锁会偏向于第一个获得它的线程。

当锁对象第一次被线程获取时,虚拟机会把对象头的标志位更改为偏向模式,同时用 CAS 操作将该线程的 ID 记录到锁对象中。

持有偏向锁的该线程以后每次进入这个锁相关的同步代码块时,虚拟机不再进行任何同步操作(例如加锁、解锁等)。

偏向锁是认为该锁只会被一个线程持有,一旦出现另一个线程取尝试获取这个锁时,虚拟机就会将对象头的标志位升级为轻量级锁模式。


② 轻量级锁

与偏向锁不同,线程每次访问轻量级锁时都需要进行同步操作,并且是使用 CAS 操作来进行加锁和解锁。

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

轻量级锁是认为可以存在多个线程,但都是友好共处,顺序持有锁,不存在竞争。假如出现多条线程竞争同一个锁的情况,虚拟机就需要将对象头进一步升级为重量级锁模式,但在升级之前可以通过多次 自旋操作 减少开销。


③ 自旋锁和自适应自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。

互斥同步对性能最大的影响就是阻塞的实现,因为挂起线程/恢复线程的操作都需要转入内核态中完成(用户态转换到内核态会耗费时间)。

一般线程持有锁的时间都不是太长,所以仅仅为了这一点时间去挂起线程/恢复线程是得不偿失的。 所以,虚拟机的开发团队就这样去考虑:“我们能不能让后面来的请求获取锁的线程等待一会而不被挂起呢?看看持有锁的线程是否很快就会释放锁”。为了让一个线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就叫做自旋。

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

另外,在 JDK1.6 中引入了自适应的自旋锁。自适应的自旋锁带来的改进就是:自旋的时间不在固定了,而是和前一次同一个锁上的自旋时间以及锁的拥有者的状态来决定,虚拟机变得越来越“聪明”了。


④ 锁消除

锁消除理解起来很简单,它指的就是虚拟机即时编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁的时间。


⑤ 锁粗化

原则上,我们在编写代码的时候,总是推荐将同步代码块的作用范围限制得尽量小——只在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也能尽快拿到锁。

但是如果一系列的连续操作都对同一个对象反复加锁和解锁,那么会带来很多不必要的性能消耗。下列代码连续的 append() 方法就属于这种情况。如果虚拟机探测到有这样一串操作都对同一个对象加锁,就会把加锁同步的范围扩展(粗化)到整个操作序列的外部,比如这段代码就是扩展到第一个 append() 操作之前直到最后一个 append() 操作之后,这样只需要加锁一次就可以了。

StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);

6. 谈谈 synchronized 和 ReentrantLock 的对比

① 两者都是可重入锁

可重入锁是指:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。


② synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

synchronized 是依赖于 JVM 实现的, JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。

ReenTrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock 方法配合 try/finally 语句块来完成)。


③ ReenTrantLock 比 synchronized 增加了一些高级功能

主要增加了三点功能:(1)等待可中断;(2)可实现公平锁;(3)可实现选择性通知(锁可以绑定多个条件)

  • 等待可中断 :正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 可实现公平锁 :公平锁就是先等待的线程先获得锁。ReentrantLock 默认情况是非公平的,可以通过 ReenTrantLock 类的 ReentrantLock(boolean fair) 构造方法来制定是否是公平的。
  • 可实现选择性通知 :synchronized 关键字与 wait() 和 notify/notifyAll() 方法相结合可以实现等待/通知机制,而 ReentrantLock 是通过 Condition 接口与 newCondition() 方法实现的。在使用 notify/notifyAll() 方法进行通知时,被通知的线程是由 JVM 选择的,用 ReentrantLock 类结合 Condition 实例可以实现“选择性通知”

如果想使用上述功能,那么选择 ReenTrantLock 是一个不错的选择。


④ 性能已不是选择标准

JDK1.6 之后,synchronized 和 ReenTrantLock 的性能基本是持平了,性能已经不是选择 synchronized 和 ReenTrantLock 的影响因素了!

而且虚拟机在未来的性能改进中会更偏向于原生的 synchronized ,所以还是提倡在 synchronized 能满足你的需求的情况下,优先考虑使用 synchronized 关键字来进行同步


2.3.4 volatile 关键字

1. 讲一下 Java 内存模型

Java 内存模型的主要目的是定义程序中各种变量(包括实例字段、静态字段、数组元素等,不包括局部变量和方法参数)的访问规则,即关注在虚拟机中 把变量值存储到内存 和 从内存中取出变量值 这样的底层细节。

在这里插入图片描述

Java内存模型规定所有的变量都存储在主内存中,每条线程还有自己的工作内存(比如机器的寄存器,具体功能则类比于 cache)。

线程对变量的所有操作都必须在工作内存中进行,不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

假如线程间变量的传递不通过主内存进行,则有可能出现数据不一致的问题,因此需要把变量声明为 volatile ,意味着告诉 JVM 这个变量是不稳定的,每次使用它都必须到主内存中读取。

说白了,volatile 关键字的主要作用就是保证变量的可见性,以及另一个作用是防止指令重排序。


补充:Java 内存模型 和 运行时数据区

Java 内存模型和之前讲的 Java 内存区域中的堆、栈、方法区等不适同一个层次的对内存的划分,这两者基本上是没有任何关系的。Java 内存模型更底层一些。

如果两者一定要勉强对应起来的话,主内存对应于 Java 堆中的对象实例数据部分,工作内存对应于虚拟机栈中的部分区域。


2. 说一说 synchronized 关键字和 volatile 关键字的区别

  • volatile 本质是在告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主内存中读取; synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  • volatile 关键字只能用于变量; synchronized 关键字可以修饰方法以及代码块
  • volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性
  • volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。

补充:为什么 volatile 不能保证原子性?

简单的说,修改volatile变量分为四步:

1)读取volatile变量到local
2)修改变量值
3)local值写回
4)插入内存屏障,即lock指令,让其他线程可见

这样就很容易看出来,前三步都是不安全的,取值和写回之间,不能保证没有其他线程修改。它仅能保证修改的值会立即被更新到主存,当有其他线程需要读取时,其他线程会去内存中读取新值,仅保证了可见性,而原子性需要锁来保证。


2.3.5 ThreadLocal

1. ThreadLocal 简介

通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。如果想实现每一个线程都有自己的专属本地变量该如何解决呢?

ThreadLocal 类主要就是让每个线程都有自己的专属本地变量。如果你创建了一个 ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的本地副本。你可以使用 get()set() 方法来获取默认值或将其值更改为当前线程所存的副本的值,从而解决了线程安全问题。


tips:每个线程都有自己私有的虚拟机栈,里面不是有局部变量表吗,里面存的不是自己私有的局部变量吗?

当局部变量是基本数据类型时,变量确实是存在于线程私有的虚拟机栈中。但当局部变量是对象引用时,对象本身存在于 Java 堆中,局部变量表存的仅仅是指向对象的引用。

但是,因为堆是全局共享的,所以在同一时间,可能有多个线程在堆上申请空间,那么,在并发场景中,如果两个线程先后把对象引用指向了同一个内存区域,就会出现并发问题。
在这里插入图片描述


2.3.6 线程池

1. 为什么要用线程池?

  • 降低资源消耗 。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
  • 提高响应速度。当任务到达时,任务可以不需要的等到线程创建就能立即执行。
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

2. 实现 Runnable 接口和 Callable 接口的区别

Callable 的目的是为了来处理 Runnable 不支持的用例。 Runnable 接口不会返回结果或抛出检查异常,但是 Callable 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口,这样代码看起来会更加简洁。

工具类 Executors 可以实现 Runnable 对象和 Callable 对象之间的相互转换。


3. 执行 execute() 方法和 submit()方法的区别是什么呢?

  • execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否
  • submit() 方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以判断任务是否执行成功,并且可以通过 Future 的 get() 方法来获取返回值,get() 方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

4. 如何创建线程池

《阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险

Executors 返回线程池对象的弊端如下:

  • FixedThreadPool 和 SingleThreadExecutor : 允许请求的队列长度为 Integer.MAX_VALUE ,可能堆积大量的请求,从而导致内存溢出。
  • CachedThreadPool 和 ScheduledThreadPool : 允许创建的线程数量为 Integer.MAX_VALUE ,可能会创建大量线程,从而导致内存溢出。

方式一:通过构造方法实现

在这里插入图片描述

方式二:通过 Executor 框架的工具类 Executors 来实现 我们可以创建三种类型的 ThreadPoolExecutor

  • FixedThreadPool : 该方法返回一个固定线程数量的线程池。该线程池中的线程数量始终不变。当有一个新的任务提交时,线程池中若有空闲线程,则立即执行。若没有,则新的任务会被暂存在一个任务队列中,待有线程空闲时,便处理在任务队列中的任务。
  • SingleThreadExecutor: 方法返回一个只有一个线程的线程池。若多于一个任务被提交到该线程池,任务会被保存在一个任务队列中,待线程空闲,按先入先出的顺序执行队列中的任务。
  • CachedThreadPool: 该方法返回一个可根据实际情况调整线程数量的线程池。线程池的线程数量不确定,但若有空闲线程可以复用,则会优先使用可复用的线程。若所有线程均在工作,又有新的任务提交,则会创建新的线程处理任务。所有线程在当前任务执行完毕后,将返回线程池进行复用。

对应 Executors 工具类中的方法如图所示:

在这里插入图片描述


2.3.7 Atomic 原子类

1. 介绍一下 Atomic 原子类

Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

原子类就是具有原子操作特征的类。

并发包 java.util.concurrent (JUC)的原子类都存放在 java.util.concurrent.atomic 下,如下图所示。

在这里插入图片描述


2. JUC 包中的原子类是哪4类?

① 基本类型

使用原子的方式更新基本类型

  • AtomicInteger:整形原子类
  • AtomicLong:长整型原子类
  • AtomicBoolean:布尔型原子类

② 数组类型

使用原子的方式更新数组里的某个元素

  • AtomicIntegerArray:整形数组原子类
  • AtomicLongArray:长整形数组原子类
  • AtomicReferenceArray:引用类型数组原子类

③ 引用类型

  • AtomicReference:引用类型原子类
  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的 ABA 问题。
  • AtomicMarkableReference :原子更新带有标记位的引用类型

④ 对象的属性修改类型

  • AtomicIntegerFieldUpdater:原子更新整形字段的更新器
  • AtomicLongFieldUpdater:原子更新长整形字段的更新器
  • AtomicReferenceFieldUpdater:原子更新引用类型字段的更新器

3. 讲讲 AtomicInteger 的使用

AtomicInteger 类常用方法:

public final int get() //获取当前的值
public final int getAndSet(int newValue)//获取当前的值,并设置新的值
public final int getAndIncrement()//获取当前的值,并自增
public final int getAndDecrement() //获取当前的值,并自减
public final int getAndAdd(int delta) //获取当前的值,并加上预期的值
boolean compareAndSet(int expect, int update) //如果输入的数值等于预期值,则以原子方式将该值设置为输入值(update)
public final void lazySet(int newValue)//最终设置为newValue,使用 lazySet 设置之后可能导致其他线程在之后的一小段时间内还是可以读到旧的值。

AtomicInteger 类的使用示例:

使用 AtomicInteger 之后,不用对 increment() 方法加锁也可以保证线程安全。

class AtomicIntegerTest {
        private AtomicInteger count = new AtomicInteger();
      //使用AtomicInteger之后,不需要对该方法加锁,也可以实现线程安全。
        public void increment() {
                  count.getAndIncrement();
        }

       public int getCount() {
                return count.get();
        }
}


4. 能不能给我简单介绍一下 AtomicInteger 类的原理

AtomicInteger 类的部分源码:

    // setup to use Unsafe.compareAndSwapInt for updates(更新操作时提供“比较并替换”的作用)
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    private volatile int value;

AtomicInteger 类主要利用 CAS (compare and swap) + volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。

CAS 的原理是拿期望的值和原本的一个值作比较,如果相同则更新成新的值。UnSafe 类的 objectFieldOffset() 方法是一个本地方法,这个方法是用来拿到“原来的值”的内存地址,返回值是 valueOffset。另外 value 是一个 volatile 变量,在内存中可见,因此 JVM 可以保证任何时刻任何线程总能拿到该变量的最新值。

即 volatile 确保了变量的可见性,CAS 确保了单个变量的原子性。

CAS 的具体原理参考博客:Java:CAS(乐观锁)


2.3.8 AQS

1. AQS介绍

AQS 的全称为(AbstractQueuedSynchronizer),这个类在 java.util.concurrent.locks 包下面。

在这里插入图片描述

AQS 是一个用来构建锁和同步器的框架,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的
ReentrantLock,Semaphore,其他的诸如 ReentrantReadWriteLock,SynchronousQueue,FutureTask 等等皆是
基于 AQS 的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。

tips: synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

  • synchronized 是依赖于 JVM 实现的, JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。

  • ReenTrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock 方法配合 try/finally 语句块来完成)。


2. AQS原理概览

AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是用 CLH 队列锁实现的,即将暂时获取不到锁的线程加入到队列中。

CLH (Craig,Landin,and Hagersten) 队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结
点之间的关联关系)。 AQS 是将每条请求共享资源的线程封装成一个 CLH 锁队列的一个结点(Node)来实现锁
的分配。

在这里插入图片描述

AQS 使用一个 int 成员变量来表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。 AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。

private volatile int state;//共享变量,使用volatile修饰保证线程可见性

状态信息通过procted类型的getState,setState,compareAndSetState进行操作:

//返回同步状态的当前值
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);
}

3. AQS 对资源的共享方式

  • Exclusive(独占):只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:
    • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁
    • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的
  • Share(共享):多个线程可同时执行,如 Semaphore/CountDownLatch。Semaphore、CountDownLatCh、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS 已经在顶层实现好了。


4. AQS底层的模板方法模式

同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用):

  1. 使用者继承 AbstractQueuedSynchronizer 并重写指定的方法。(这些重写方法很简单,无非是对于共享资源 state 的获取和释放)
  2. 将AQS组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的方法。

自定义同步器时需要重写下面几个AQS提供的模板方法:

isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。

默认情况下,每个方法都抛出 UnsupportedOperationException 。 这些方法的实现必须是内部线程安全的。AQS 类中的其他方法都是 final ,所以无法被其他类使用,只有这几个方法可以被其他类使用。

ReentrantLock (独占式)为例,state 初始化为0,表示未锁定状态。A 线程 lock() 时,会调用 tryAcquire() 独占该锁并将 state+1。此后,其他线程再 tryAcquire() 时就会失败,直到 A 线程 unlock() 到 state=0(即释放锁)为止,其它线程才有机会获取该锁。当然,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加),这就是可重入的概念。但要注意,获取多少次就要释放多么次,这样才能保证 state 是能回到0的。

再以 CountDownLatch (共享式)以例,任务分为 N 个子线程去执行,state 也初始化为 N(注意N要与线程个数一致)。这 N 个子线程是并行执行的,每个子线程执行完后 countDown() 一次,state 会 CAS 减1。等到所有子线程都执行完后(即 state=0 ),会 unpark() 主调用线程,然后主调用线程就会从 await() 函数返回,继续后余动作。

5. AQS 组件总结

  • Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。

  • CountDownLatch (倒计时器): CountDownLatch 是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。

  • CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的计数等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier默认的构造方法是 CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用 await 方法告诉 CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞。


2.3.9 线程安全的单例模式的几种实现方式

1. 懒汉式

是否 Lazy 初始化:是

public class Singleton {  
    private static Singleton instance;  
    
    private Singleton (){}  
    
    public static synchronized Singleton getInstance() {  
	    if (instance == null) {  
	        instance = new Singleton();  
	    }  
	    return instance;  
    }  
}

2. 饿汉式

是否 Lazy 初始化:否

public class Singleton {  

    private static Singleton instance = new Singleton();  
    
    private Singleton (){}  
    
    public static Singleton getInstance() {  
    	return instance;  
    }  
}

3. 双重校验锁

是否 Lazy 初始化:是

public class Singleton {  

    private volatile static Singleton singleton;  
    
    private Singleton (){}  
    
    public static Singleton getSingleton() {  
	    if (singleton == null) {  
	        synchronized (Singleton.class) {  
		        if (singleton == null) {  
		            singleton = new Singleton();  
		        }  
	        }  
	    }  
	    return singleton;  
    }  
}

双重校验锁是懒汉式的升级,具体表现在哪呢?

上述懒汉式的写法等价于:

public class Singleton {  
    private static Singleton instance;  
    
    private Singleton (){}  
    
    public static Singleton getInstance() {  
    	synchronized (Singleton.class) {
		    if (instance == null) {  
		        instance = new Singleton();  
		    }  
		    return instance;  
	    }
    }  
}

懒汉式会导致:每来一个线程,哪怕该对象已经创建了,仍然需要加一次锁,这就导致了很多额外的开销。

因此,双重校验锁在加锁之前,又加了一层判断 if (singleton == null) ,避免了加锁的额外开销。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值