第十六章 进程、线程、同步锁和线程安全问题
文章目录
一、进程
1.基本介绍
进程是操作系统中最核心的概念,进程是对正在运行中的程序的一个抽象。操作系统的其他所有内容都是围绕着进程展开的。即使可以使用的 CPU 只有一个,它们也支持(伪)并发操作。它们会将一个单独的 CPU 抽象为多个虚拟机的 CPU。可以说:没有进程的抽象,现代操作系统将不复存在
2.进程模型
在进程模型中,所有计算机上运行的软件,通常也包括操作系统,被组织为若干顺序进程(sequential processes),简称为进程(process) 。一个进程就是一个正在执行的程序的实例,进程也包括程序计数器、寄存器和变量的当前值。从概念上来说,每个进程都有各自的虚拟 CPU,但是实际情况是 CPU 会在各个进程之间进行来回切换
如上图所示,这是一个具有 4 个程序的多道处理程序,在进程不断切换的过程中,程序计数器也在不断地变化
在上图中,这 4 道程序被抽象为 4 个拥有各自控制流程(即每个自己的程序计数器)的进程,并且每个程序都独立的运行。当然,实际上只有一个物理程序计数器,每个程序要运行时,其逻辑程序计数器会装载到物理程序计数器中。当程序运行结束后,其物理程序计数器就会是真正的程序计数器,然后再把它放回进程的逻辑计数器中
从上图我们可以看到,在观察足够长的一段时间后,所有的进程都运行了,但在任何一个给定的瞬间仅有一个进程真正运行
因此,当我们说一个 CPU 只能真正一次运行一个进程的时候,即使有 2 个核(或 CPU),每一个核也只能一次运行一个线程
由于 CPU 会在各个进程之间来回快速切换,所以每个进程在 CPU 中的运行时间是无法确定的。并且当同一个进程再次在 CPU 中运行时,其在 CPU 内部的运行时间往往也是不固定的。进程和程序之间的区别是非常微妙的,但是通过一个例子可以让你加以区分:想想一位会做饭的计算机科学家正在为他的女儿制作生日蛋糕。他有做生日蛋糕的食谱,厨房里有所需的原料:面粉、鸡蛋、糖、香草汁等。在这个比喻中,做蛋糕的食谱就是程序、计算机科学家就是 CPU、而做蛋糕的各种原料都是输入数据。进程就是科学家阅读食谱、取来各种原料以及烘焙蛋糕等一系例了动作的总和
现在假设科学家的儿子跑过来告诉他,说他的头被蜜蜂蜇了一下,那么此时科学家会记录出来他做蛋糕这个过程到了哪一步,然后拿出急救手册,按照上面的步骤给他儿子实施救助。这里,会涉及到进程之间的切换,科学家(CPU)会从做蛋糕(进程)切换到实施医疗救助(另一个进程)。等待伤口处理完毕后,科学家会回到刚刚记录做蛋糕的那一步,继续制作
二、线程
1.基本介绍
在传统的操作系统中,每个进程都有一个地址空间和一个控制线程。事实上,这是大部分进程的定义。不过,在许多情况下,经常存在同一地址空间中运行多个控制线程的情形,这些线程就像是分离的进程
Java 给多线程编程提供了内置的支持。 一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务
多线程是多任务的一种特别的形式,但多线程使用了更小的资源开销
为什么要在进程的基础上再创建一个线程的概念?
- 多线程之间会共享同一块地址空间和所有可用数据的能力,这是进程所不具备的
- 线程要比进程更轻量级,由于线程更轻,所以它比进程更容易创建,也更容易撤销。在许多系统中,创建一个线程要比创建一个进程快 10 - 100 倍
- 第三个原因可能是性能方面的探讨,如果多个线程都是 CPU 密集型的,那么并不能获得性能上的增强,但是如果存在着大量的计算和大量的 I/O 处理,拥有多个线程能在这些活动中彼此重叠进行,从而会加快应用程序的执行速度
现在考虑一个线程使用的例子:一个万维网服务器,对页面的请求发送给服务器,而所请求的页面发送回客户端。在多数 web 站点上,某些页面较其他页面相比有更多的访问。例如,索尼的主页比任何一个照相机详情介绍页面具有更多的访问,Web 服务器可以把获得大量访问的页面集合保存在内存中,避免到磁盘去调入这些页面,从而改善性能。这种页面的集合称为 高速缓存(cache),高速缓存也应用在许多场合中,比如说 CPU 缓存
上面是一个 web 服务器的组织方式,一个叫做 调度线程(dispatcher thread) 的线程从网络中读入工作请求,在调度线程检查完请求后,它会选择一个空闲的(阻塞的)工作线程来处理请求,通常是将消息的指针写入到每个线程关联的特殊字中。然后调度线程会唤醒正在睡眠中的工作线程,把工作线程的状态从阻塞态变为就绪态
当工作线程启动后,它会检查请求是否在 web 页面的高速缓存中存在,这个高速缓存是所有线程都可以访问的。如果高速缓存不存在这个 web 页面的话,它会调用一个 read 操作从磁盘中获取页面并且阻塞线程直到磁盘操作完成。当线程阻塞在硬盘操作的期间,为了完成更多的工作,调度线程可能挑选另一个线程运行,也可能把另一个当前就绪的工作线程投入运行
这种模型允许将服务器编写为顺序线程的集合,在分派线程的程序中包含一个死循环,该循环用来获得工作请求并且把请求派给工作线程。每个工作线程的代码包含一个从调度线程接收的请求,并且检查 web 高速缓存中是否存在所需页面,如果有,直接把该页面返回给客户,接着工作线程阻塞,等待一个新请求的到达。如果没有,工作线程就从磁盘调入该页面,将该页面返回给客户机,然后工作线程阻塞,等待一个新请求
2.线程的生命周期
- 新建状态:使用 new 关键字 和 Thread 类或其子类建立一个线程对象后,该线程对象就处于新建状态。它保持这个状态直到程序 start() 这个线程
- 就绪状态:当线程对象调用了 start() 方法之后,该线程就进入就绪状态。就绪状态的线程处于就绪队列中,要等待 JVM 里线程调度器的调度
- 运行状态:如果就绪状态的线程获取 CPU 资源,就可以执行 run(),此时线程便处于运行状态。处于运行状态的线程最为复杂,它可以变为阻塞状态、就绪状态和死亡状态
- 阻塞状态:如果一个线程执行了 sleep(睡眠)、suspend(挂起)等方法,失去所占用资源之后,该线程就从运行状态进入阻塞状态。在睡眠时间已到或获得设备资源后可以重新进入就绪状态。进入阻塞状态的方有三种:
- 等待状态:运行状态中的线程执行 wait() 方法,使线程进入到等待阻塞状态
- 同步阻塞:线程获取 synchronized 同步锁失败(因为同步锁被其他线程占用)
- 其他阻塞:通过调用线程的 sleep() 或 join() 发出了 I/O 请求时,线程就会进入到阻塞状态。当 sleep() 状态超时,join() 等待线程终止或超时,或者 I/O 处理完毕,线程重新转入就绪状态
- 死亡状态:一个运行状态的线程完成任务或者其他终止条件发生时,该线程就切换到终止状态
3.线程的优先级
每个 Java 线程都有一个优先级,这样有助于操作系统确定线程的调度顺序
Java 线程的优先级是一个整数,其取值范围是 1(Thread.MIN_PRiority)- 10(Thread.MAX_PRIORITY)
默认情况下,每一个线程就会分配一个优先级 NORM_PRIORITY(5)
具有较高优先级的线程对程序更重要,并且应该在低优先级的线程之前分配处理器资源。但是,线程优先级不能保证线程执行的顺序,而且非常依赖于平台
4.继承Thread类创建线程
一般步骤:
- 定义 Thread 类的子类,并重写该类的 run() 方法,该方法的方法体就是线程需要完成的任务,run() 方法也称为线程执行体
- 创建 Thread 子类的实例,也就是创建了线程对象
- 启动线程,即调用线程的 start() 方法
package com.sisyphus.thread;
/**
* @Description: 多线程实现方式一$
* @Param: $
* @return: $
* @Author: Sisyphus
* @Date: 7/21$
*/
/**线程的随机性:多个线程对象执行的效果是不可控的,因为 CPU 会调度处理
* 结果具有随机性,至于哪个时间片让哪个线程执行,时间片有多长,我们都控制不了*/
public class TestThread {
public static void main(String[] args) {
//4.创建线程对象进行测试
MyThread t = new MyThread();/**3.对应的线程状态是新建状态*/
//5.模拟多线程,再创建一个线程对象
MyThread t2 = new MyThread();
MyThread t3 = new MyThread();
MyThread t4 = new MyThread();
t.start();/**start() 才会把线程加入到就绪队列,以多线程的方式运行*/
t2.start();
t3.start();
t4.start();
// t.run();/**5.run() 就是普通方法的调用,不会出现多线程的效果*/
// t2.run();
}
}
//1.自定义多线程类
/**1.方式1:extends Thread*/
class MyThread extends Thread{
public MyThread(String name) {
//子类构造方法触发父类的构造方法给线程对象起名字
super(name);
}
public MyThread() {
}
//2.1线程中的业务必须写在 run() 里,我们不执行父类的 run() ,有自己的业务
@Override
public void run() {
//3.完成自己的业务:输出 10 次当前正在执行的线程名称
for (int i = 0; i < 10; i++) {
/**2.getName() 可以获取当前正在执行的线程名称
* 由于是从父类继承过来的方法,所以可以通过方法名直接使用*/
System.out.println(i+" = "+getName());
}
}
}
5.实现Runnable接口创建线程
一般步骤:
- 定义 Runnable 接口的实现类,并重写该接口的 run() 方法,该 run() 方法的方法体同样是该线程的线程执行体
- 创建 Runnable 实现类的实例,并以此实例作为 Thread 的 target 来创建 Thread 对象,该 Thread 对象才是真正的线程对象
- 调用线程对象的 start() 方法来启动该线程
package com.sisyphus.thread;
/**
* @Description: 本类用于多线程编程实现方案二$
* @Param: $
* @return: $
* @Author: Sisyphus
* @Date: 7/21$
*/
public class TestRunnable {
public static void main(String[] args) {
//3.创建对象进行测试
MyRunnable target = new MyRunnable();
//target.run();
//4.把接口实现类对象与 Thread 类建立关系
Thread t1 = new Thread(target);
Thread t2 = new Thread(target);
Thread t3 = new Thread(target);
t1.start();
t2.start();
t3.start();
}
}
//1.自定义多线程类
/**方式二:implements Runnable*/
class MyRunnable implements Runnable{
@Override
public void run() {
//需求:打印 10 次当前正在执行的线程名称
for (int i = 1; i <= 10; i++) {
//问题:Runnable 接口中只有一个抽象方法 run(),所以我们需要求助 “外援”
//Thread.currentThread()
//获取当前正在执行的线程对象,静态方法可以被类名直接调用
//线程对象.getName() 获取当前正在执行的线程对象的名称
System.out.println(i + " = " + Thread.currentThread().getName());
}
}
}
6.使用Callable接口和FutureTask类创建线程
从 Java 5 开始,Java 提供了 Callable 接口,该接口是 Runnable 接口的增强版,Callable 接口提供了一个 call() 方法,可以看作是线程的执行体,但 call() 方法比 run() 方法更强大
一般步骤:
- 创建 Callbale 接口的实现类,并实现 call() 方法,该 call() 方法将作为该线程的执行体,且该 call() 方法有返回值,再创建 Callable 的实例,从 Java 8 开始,可以直接使用 Lamda 表达式(Lambda 允许把函数作为一个方法的参数)创建 Callable 对象
- 使用 FutureTask 类来包装 Callable 对象,该 FutureTask 对象封装了该 Callable 对象的 call() 方法的返回值
- 使用 FutureTask 对象作为 Thread 对象的 Thread 对象的 target,创建并启动新线程
- 调用 FutureTask 对象的 get() 方法来获得子线程执行结束后的返回值
简而言之:创建 Callable 接口的实现类,并实现 call() 方法。并使用 FutureTask 类来包装 Callable 实现类的对象,且以此 FutureTask 对象作为 Thread 对象的 target 来创建线程
FutureTask 类实际上是同时实现了 Runnable 和 Future 接口,由此才使得其具有 Future 和 Runnable 双重特性。通过 Runnable 特性,可以作为 Thread 对象的 target,而 Future 特性,使得其可以取得新创建线程中的 call() 方法的返回值
package com.sisyphus.thread;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
/**
* @Description: 本类用于多线程编程实现方案三$
* @Param: $
* @return: $
* @Author: Sisyphus
* @Date: 7/22$
*/
public class TestCallable {
public static void main(String[] args) {
Callable<Integer> myCallable = new MyCallable(); //创建 MyCallable 对象
FutureTask<Integer> ft = new FutureTask<Integer>(myCallable);//使用 FutureTask 来包装 MyCallable 对象
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + " " + i);
if (i == 3){
Thread thread = new Thread(ft); //FutureTask 对象作为 Thread 对象的 target 创建新的线程
thread.start();
}
}
System.out.println("主线程 for 循环执行完毕……");
//取得新创建的新线程中的 call() 方法返回的结果
try {
int sum = ft.get();
System.out.println("sum = " + sum);
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
class MyCallable implements Callable<Integer>{
private int i = 0;
//与 run() 方法不同的是,call() 方法具有返回值
@Override
public Integer call(){
int sum = 0;
for (int i = 1; i <= 10; i++){
System.out.println(i + " = " +Thread.currentThread().getName());
sum++;
}
return sum;
}
}
7.三种创建方式对比
继承 Thread 类创建线程
优势:
- 编写简单,如果需要访问当前线程,则无需使用 Thread.currentThread() 方法,直接使用 this 即可获得当前线程
劣势:
- 已经继承了 Thread,所以不能再继承其他父类
实现 Runnable 或 Callable 接口创建线程
优势:
- 线程类只是实现了接口,还可以继承其他父类
- 在这种方式下,多个线程可以共享同一个 target 对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将 CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想
劣势:
- 编程稍微复杂,如果要访问当前线程,则必须使用 Thread.currentThread() 方法
Runnable 和 Callable 的区别
- Callable 规定(重写)的方法是 call(),Runnable 规定(重写)的方法是 run()
- Callabe 的任务执行后可以返回值,而 Runable 不能
- call() 方法可以抛出异常,run() 不行
- 运行 Callable 任务可以拿到一个 Future 对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过 Future 对象可以了解任务执行的情况,可以取消任务的执行,还可以获取执行结果
8.线程池创建线程
package com.sisyphus.thread;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @Description: 本类用于多线程编程实现方案二$
* @Param: $
* @return: $
* @Author: Sisyphus
* @Date: 7/21$
*/
public class TestExecutorService {
public static void main(String[] args) {
runnable target = new runnable();
//创建线程池
/**创建线程池的工具:Executors 使用
* newFixedThreadPool(线程数)方法来创建出指定线程数的线程池
* 创建出来的线程池类型:ExecutorService
* 线程池ExecutorService:用来存储线程的池子,把新建线程/启动线程/关闭线程的任务都交给池来管理*/
ExecutorService pool = Executors.newFixedThreadPool(5);
for (int i = 0; i < 5; i++) {
//本方法的参数就是执行的业务,也就是实现类的目标对象
pool.execute(target);
}
}
}
class runnable implements Runnable{
@Override
public void run() {
for (int i = 1; i <= 10; i++) {
System.out.println(i + " = " + Thread.currentThread().getName());
}
}
}
三、同步锁
1.引入
我们先来看一个经典的多线程案例
有 4 个窗口模拟售票,同时售票共计 100 张,售完为止
使用两种不同的方式实现
package com.sisyphus.ticket;
/**
* @Description: 本类通过继承的方式实现多线程售票案例$
* @Param: $
* @return: $
* @Author: Sisyphus
* @Date: 7/21$
*/
//需求:4 个窗口模拟售票,同时售票共计 100 张,售完为止
public class TicketThread {
public static void main(String[] args) {
//5.创建多线程对象进行测试
TicketT t1 = new TicketT();
TicketT t2 = new TicketT();
TicketT t3 = new TicketT();
TicketT t4 = new TicketT();
//6.以多线程的方式启动线程
t1.start();
t2.start();
t3.start();
t4.start();
}
}
//1.自定义多线程类 -- 完成售票业务
class TicketT extends Thread{
//3.定义变量,用于保存票数
//int tickets = 100;//共计 100 张票
//7.1问题:出现了售卖 400 张票的情况,原因是每 new 一次对象,就有 100 张票
//解决方案:需要把票数变为静态资源,被本类的所有对象共享
static int tickets = 100;//共计 100 张
//2.业务写在 run() 中
@Override
public void run() {
//super.run(); -- 表示调用父类的业务,我们不用
//4.1通过死循环完成卖票,只要有票就一直运行
while(true){
//8.让程序休眠 10ms -- 休眠中断阻塞
/**一个程序只有经受住了休眠的考验,才说明没有数据安全隐患
* 我们手动添加休眠方法,是为了更快地暴露出程序中可能出现的问题
* 问题1:重卖:同一张票卖给了多个人
* 问题2:超卖:超出了实际的票数,出现了负数*/
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//4.2打印当前正在卖票的线程名称和票数
System.out.println(getName()+"="+tickets--);
//4.3做判断,如果没票了,就退出循环
//如果 if 判断语句后只有一句话,大括号可以省略不写
if (tickets <= 0) break;
}
}
}
package com.sisyphus.ticket;
/**
* @Description: 本类通过实现接口的方式实现多线程售票案例$
* @Param: $
* @return: $
* @Author: Sisyphus
* @Date: 7/21$
*/
public class TicketRunnable {
public static void main(String[] args) {
/**target 是统一的目标业务对象
* 而 Thread 类对象 t1 t2 t3 ...是多线程对象*/
//5.创建目标业务对象
TicketR target = new TicketR();
//6.把 target 业务对象作为参数传给 Thread 类地含参构造
Thread t1 = new Thread(target);
Thread t2 = new Thread(target);
Thread t3 = new Thread(target);
Thread t4 = new Thread(target);
//7.以多线程的方式启动线程对象
t1.start();
t2.start();
t3.start();
t4.start();
}
}
//1.自定义多线程类
class TicketR implements Runnable{
//3.定义变量用于存票
int tickets = 100;
//2.添加接口中未实现的抽象方法,主要用于完成业务
@Override
public void run() {
//4.通过循环结构完成售票
while(true){ //死循环一定要设置出口!
//8.给程序主动设置休眠,设置阻塞,暴露数据安全问题
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//4.1打印当前正在售票地线程名称,并且票数 -1
System.out.println(Thread.currentThread().getName() + " = " + tickets--);
//4.2如果没有票了,循环结束
if(tickets <= 0) break;
}
}
}
问题1:重卖:同一张票卖给了多个人
tickets = 99
tickets = 99
CPU 的每一次操作都是原子性的(最简单的)操作
但是 tickets–; 不是原子性的操作
它要先记录以前的值(在 tickets-- 前,t2 进入了 while 循环,它也记录了 100,所以出现了相同的票)
接着 tickets–
打印卖了第 100 张票
问题2:超卖:超出了实际的票数,出现了负数
tickets = 0
tickets = -1
tickets = -2
某一时刻 tickets = 1
t1 进入 while 循环,休眠 10ms
t2 进入 while 循环,休眠 10ms
t3 进入 while 循环,休眠 10ms
(休眠结束后才进行 tickets–,判断退出循环的语句更不用谈了,还要等到 tickets-- 后才会执行)
窗口正在售票,tickets = 0;
窗口正在售票,tickets = -1;
窗口正在售票,tickets = -2;
(此时才判断 tickets 是否小于等于 0,并退出循环)
为了解决上述问题,我们可以使用 synchronized 关键字来实现同步效果
2.synchronized同步关键字
同步与异步的概念:
- 同步:体现了排队的效果,同一时刻只能有一个线程独占资源,其他没有权利的线程排队。
坏处就是效率会降低,不过保证了安全 - 异步:体现了多线程抢占资源的效果,线程间互相不等待,互相抢占资源。
坏处就是有安全隐患,效率要高一些
synchronized 关键字语法格式
synchronized(锁对象){需要同步的代码块}
注意:
- 多个线程间必须使用同一个锁
- 为了性能,加锁的范围需要控制好
- synchronized 同步关键字可以用来修饰方法,称为同步方法,使用的锁对象是 this
- synchronized 同步关键字可以用来修饰代码块,称为同步代码块,使用的锁对象可以任意
为什么同步代码块的锁对象可以是任意的同一个对象,但是同步方法使用的是this呢?
因为同步代码块可以保证同一个时刻只有一个线程进入
但同步方法不可以保证同一时刻只能有一个线程调用,所以使用本类代指对象this来确保同步
改进售票案例,加入 synchronized 关键字,并在休眠前先判断 tickets 是否小于等于 0
package com.sisyphus.ticket;
/**
* @Description: 本类通过继承的方式实现多线程售票案例$
* @Param: $
* @return: $
* @Author: Sisyphus
* @Date: 7/21$
*/
//需求:4 个窗口模拟售票,同时售票共计 100 张,售完为止
public class TicketThreadV2 {
public static void main(String[] args) {
//5.创建多线程对象进行测试
TicketTV2 t1 = new TicketTV2();
TicketTV2 t2 = new TicketTV2();
TicketTV2 t3 = new TicketTV2();
TicketTV2 t4 = new TicketTV2();
//6.以多线程的方式启动线程
t1.start();
t2.start();
t3.start();
t4.start();
}
}
//1.自定义多线程类 -- 完成售票业务
class TicketTV2 extends Thread{
//3.定义变量,用于保存票数
//int tickets = 100;//共计 100 张票
//7.1问题:出现了售卖 400 张票的情况,原因是每 new 一次对象,就有 100 张票
//解决方案:需要把票数变为静态资源,被本类的所有对象共享
static int tickets = 100;//共计 100 张
//2.业务写在 run() 中
@Override
public void run() {
//super.run(); -- 表示调用父类的业务,我们不用
//4.1通过死循环完成卖票,只要有票就一直运行
while(true){
synchronized(TicketTV2.class){ //当前类的字节码对象,反射技术,下一章节会进行学习
if (tickets > 0){
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(getName()+"="+tickets--);
}
if (tickets <= 0) break;
}
}
}
}
package com.sisyphus.ticket;
/**
* @Description: 本类通过实现接口的方式实现多线程售票案例$
* @Param: $
* @return: $
* @Author: Sisyphus
* @Date: 7/21$
*/
public class TicketRunnableV2 {
public static void main(String[] args) {
/**target 是统一的目标业务对象
* 而 Thread 类对象 t1 t2 t3 ...是多线程对象*/
//5.创建目标业务对象
TicketRV2 target = new TicketRV2();
//6.把 target 业务对象作为参数传给 Thread 类地含参构造
Thread t1 = new Thread(target);
Thread t2 = new Thread(target);
Thread t3 = new Thread(target);
Thread t4 = new Thread(target);
//7.以多线程的方式启动线程对象
t1.start();
t2.start();
t3.start();
t4.start();
}
}
//1.自定义多线程类
class TicketRV2 implements Runnable{
//3.定义变量用于存票
int tickets = 100;
Object o = new Object();
//2.添加接口中未实现的抽象方法,主要用于完成业务
@Override
public void run() {
//4.通过循环结构完成售票
while(true){
//解决方案:定义一个同步代码块,注意锁对象需要唯一
synchronized (o){
if (tickets > 0){
//8.给程序主动设置休眠,设置阻塞,暴露数据安全问题
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
//4.1打印当前正在售票地线程名称,并且票数 -1
System.out.println(Thread.currentThread().getName() + " = " + tickets--);
//4.2如果没有票了,循环结束
}
if(tickets <= 0) break;
}
}
}
}
如果进行测试了的话,会发现控制台打印数据的速度明显变慢了,说明效率降低了,但是为了安全性,这是不可避免的
四、线程锁的简介
1.公平锁/非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁
非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取,因此也会导致优先级反转或者饥饿现象
非公平锁的优点在于吞吐量比公平锁大
对于 Java ReentrantLock 而言,通过构造函数指定该锁是否为公平锁,且默认为非公平锁
synchronized 也是一种非公平锁,但是由于它不像 ReentrantLock 是通过 AQS(AbstractQueuedSynchronizer,抽象的队列式的同步器)来实现线程调度的,所以并没有任何办法使其变成公平锁
2.可重入锁
可重入锁又称递归锁,是指同一线程在外层方法获取锁后,在进入内层方法时会自动获取锁
可重入锁的一个好处是可一定程度避免死锁
Java ReentrantLock,其名字 ReentrantLock 的意思就是重新进入锁
对于 synchronized 而言,它也是一个一个可重入锁
3.独享锁/共享锁
独享锁是指该锁一次只能被一个线程所持有;共享锁是指该锁可被多个线程持有
独享锁与共享锁也是通过 AQS 来实现的,通过实现不同的方法,来实现独享或者共享
对于 Java ReentrantLock 而言,它是独享锁
对于 Lock 的另一个实现类 ReadWriteLock 而言,它读锁是共享锁,写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写、写读、写写的过程是互斥的
对于 synchronized 而言,当然是独享锁
4.互斥锁/读写锁
上面的独享锁/共享锁是一种广义的说法,互斥锁/读写锁是具体的实现
互斥锁在 Java 中的具体体现就是 ReentrantLock
读写锁在 Java 中的具体实现就是 ReadWriteLock
5.乐观锁/悲观锁
乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度
悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到能获取锁
Java 里面的同步原语 synchronized 关键字的实现就是悲观锁
乐观锁:顾名思义,假设每次去拿数据的时候都认为别人不会修改,所以不会上锁,只会在更新数据的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制
在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是通过乐观锁的一种实现方式 CAS(Compare and Swap,比较并交换)实现的
乐观锁适用于多读的应用类型,这样可以提高吞吐量
6.分段锁
分段锁其实是一种锁的设计,并不是具体的一种锁,对于 ConcurrentHashMap 而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作,ConcurrentHashMap 中的分段锁称为 Segment,它即类似于 HashMap(JDK 7 与 JDK 8 中 HashMap 的实现)的结构,即内部拥有一个 Entry 数组,数组中的每个元素又是一个链表,同时又是一个 ReentrantLock(Segment 继承了 ReentrantLock)。当需要 put 元素的时候,并不是对整个 HashMap 进行加锁,而是先通过 hashcode 来知道他要放在哪个分段中,然后对这个分段进行加锁,所以当多线程 put 的时候,只要不是放在一个分段中,就实现了真正的并行的插入。但是,在统计 size 的时候,就是获取 HashMap 全局信息的时候,需要获取所有的分段锁才能统计
分段锁的设计目的是细化锁的粒度,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作
7.偏向锁/轻量级锁/重量级锁
这三种锁是指锁的状态,并且是针对 synchronized 的,在 Java 5 通过引入锁升级的机制来实现高效 synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,降低获取锁的代价
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高了性能
重量级锁是指当锁是轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁会膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,导致性能降低
8.自旋锁
在 Java 中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗 CPU