> 文章列表 > 多线程【进阶版】

多线程【进阶版】

多线程【进阶版】

目录

一. 常见的锁策略

1.1 乐观锁和悲观锁

1.2 轻量级锁和重量级锁

1.3 自旋锁和挂起等待锁

1.4 互斥锁和读写锁

1.5 可重入锁和不可重入锁

1.6 公平锁和非公平锁

1.7 关于锁策略的相关面试题

二. CAS

三. Synchronized 原理

3.1 基本特点

3.2 加锁步骤

3.3 锁消除 

3.4 锁粗化 

3.5 JUC常见组件

 四. 线程安全的集合类

4.1 HashTable 和 ConcurrentHashMap的区别

4.2 多线程相关面试题


一. 常见的锁策略

1.1 乐观锁和悲观锁

说到乐观和悲观这两个概念,大家都不陌生,生活中我们也要常常面对一些乐观和悲观的时候,但是这是站在自身的角度去看待的,有的人看待一件事他认为是乐观的,而有的人认为他是悲观的;这里的 "乐观" 和 "悲观" 和我们说的乐观锁和悲观锁也是很相似的;

乐观锁:每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据;

举个例子:假设有A 和 B 两个线程,他们要去获取数据,因为他们是乐观的,所以双方不会认为他们去修改数据,所以他们就拿到数据后执行各自的事情去了,还有一个特点就是线程A和B在更新共享数据之前,他们要去判断这个共享数据是否被其他线程修改,如果没有修改的话,那么就直接更新内存中共享变量的值,那如果被修改了,就会报错或者去执行其他相关的操作了


悲观锁:每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁;

举个例子:还是有A 和 B两个线程,A 和 B要去拿数据,因为它是悲观的,所以在拿数据时需要进行加锁,假设A拿到了锁,那么B就会进入阻塞等待的状态,知道A释放锁,CPU会唤醒等待的线程B,B才能拿到这个锁,从而对数据进行操作;


总体来说,悲观锁一般要做的工作多一点,效率会更低一些;而乐观锁要做的事少一点,效率更高一点; 


1.2 轻量级锁和重量级锁

轻量级锁:加锁和解锁的过程中更快更高效;

重量级锁:加锁和解锁的过程中更慢更低效;

这里看来,轻量级,重量级虽然和乐观,悲观不是一回事,但是有那么一定的相似,可以认为一个乐观锁可能是一个轻量级锁,但不是绝对的;关于这块后面还是会细说;


1.3 自旋锁和挂起等待锁

自旋锁是轻量级锁的一种代表实现,当一个线程尝试去获取某一把锁的时候,如果这个锁此时已经被别人获取(占用),那么此线程就无法获取到这把锁,该线程将会等待,间隔一段时间后会再次尝试获取。这种采用循环加锁 -> 等待的机制被称为自旋锁(spinlock);

优点:自旋锁一旦被释放,就能第一时间拿到锁,自旋锁是纯用户态操作,所以速度很快;

缺点:要一直等待,会消耗CPU资源

挂起等待锁是重量级锁的一直代表实现,当某个线程没有申请到锁的时候,此时该线程会被挂起,即加入到等待队列等待。当锁被释放的时候,就会被唤醒,重新竞争锁;

优点:不需要盲等,在等待的过程中可以参与别的事情,充分利用了CPU的资源;

缺点:如果锁被释放,不能第一时间拿到锁,挂起等待锁是通过内核的机制来实现,所以时间会更长,效率会更低;


1.4 互斥锁和读写锁

互斥锁:互斥锁是一个非常霸道的存在,比如有线程A,B,当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞,

我们学过的Synchronized就是互斥锁;

对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。如下图:

注:互斥锁和自旋锁最大的区别:

  • 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
  • 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;

读写锁:它由 读锁 和 写锁 两部分构成,如果只读取共享资源用 读锁 加锁,如果要修改共享资源则用 写锁 加锁。

读写锁一般有 3 种情况:

1.给读加锁

2.给写加锁

3.解锁

读写锁中的约定:

  • 读锁和读锁之间,不会有锁竞争,不会产生阻塞等待
  • 写锁和写锁之间,有锁竞争
  • 读锁和写锁之间,有锁竞争

1.5 可重入锁和不可重入锁

针对一个线程,针对一把锁,连续加锁两次,如果出现死锁了,那就是不可重入锁,如果不死锁,那就是可重入锁;

Object locker = new Object();synchronized(locker){synchronized(locker){}
}

像上述这样的代码就是加锁两次的情况,第二次加锁需要等待第一个锁释放,第一个锁释放,需要等待第二个锁加锁成功,所以这种情况就矛盾了,但是并不会真正的死锁,因为synchronized是可重入锁,加锁的时候会先判定一下,看当前尝试申请锁的线程是不是已经拥有锁了,如果是的话,就不会矛盾;

synchronized 和   ReentrantLock 都是可重入锁,可重入锁最大的意思就是为了防止死锁;


1.6 公平锁和非公平锁

 首先从字面意思理解,先到的线程会优先获取资源,后到的会进行排队等待,这种是公平的,而非公平锁是不遵循这个原则的,其实也很好理解,看下图:

这种情况就是公平的,遵循先到先得的规矩;

 而像这种情况,就是非公平的,存在 "插队" 的现象;


1.7 关于锁策略的相关面试题

1. 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?

悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁;

乐观锁认为多个线程访问同一个共享变量冲突的概率不大,并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突;

悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待;

2. 介绍下读写锁?

读写锁就是把读操作和写操作分别进行加锁;

读锁和读锁之间不互斥;

写锁和写锁之间互斥;

写锁和读锁之间互斥;

读写锁最主要用在 "频繁读,不频繁写" 的场景中;

3. 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?

如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.

优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用.
缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源

4. synchronized 是可重入锁么?
是可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁

5. synchronized的特点:

  1. 既是乐观锁,也是悲观锁
  2. 既是轻量级锁,也是重量级锁
  3. 轻量级锁是基于自选锁实现的,重量级锁是基于挂起等待锁实现的
  4. 不是读写锁
  5. 是可重入锁
  6. 是非公平锁

二. CAS

1. 概念

全称Compare and swap,比较 内存A 和 寄存器R 中的数据值,如果数值相同,就把 内存B和 寄存器R 的数值进行交换;

2. 特点

CAS最大的特点就是一个CAS操作是单条CPU指令,它是原子的,所以既是CAS不加锁,也能保证线程的安全;

在JDK1.8中针对于CAS提供了一个类:


CAS中的ABA问题:

ABA问题是CAS中的面试的高频问题,我们都知道,CAS是对比内存和寄存器的值,看看是否相同,就是通过这个对比,来检测内存是不是改变过,要么相同,要么不同,不同都好区别,但是有一种相同不是真正意义上的相同,而是不确定这个值中间是否发生过改变,改变了原来的东西,但是变回到原来的状态,假设原来是a,然后变成b,后来又变成a,这就是 a->b->a 问题了;


如何解决ABA问题呢???

ABA问题实质上就是一个反复横跳的问题,我们只要约定数据只能单方面变化,要么数据只能增加,要么数据只能减小,那么问题就迎刃而解了;

如果我们要求数据既能增大又能减小,我们可以约定一个版本号变量,约定版本号只能增加,并且每次修改,都会增加一个版本号,这样我们每次对比的时候就是拿版本号去对比,而不是对比数值本身,这样也能很好的解决问题了;


三. Synchronized 原理

3.1 基本特点

  • 1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
  • 2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
  • 3. 实现轻量级锁的时候大概率用到的自旋锁策略
  • 4. 是一种不公平锁
  • 5. 是一种可重入锁
  • 6. 不是读写锁

3.2 加锁步骤

过程:

  1. 最开始是没有锁的;
  2. 刚开始加锁,是一个偏向锁状态;
  3. 遇到锁竞争,就转化为轻量级锁状态(自旋锁);
  4. 竞争激烈时,就会变成重量级锁,这一过程交给内核阻塞等待;

关于偏向锁:

偏向锁并非是真的加锁,只是简单的标记一下,想占有这把锁,如果没有锁竞争,那就不加锁,如果有别的线程来竞争这把锁,那么就升级成轻量级锁,这样做既保证了效率,又保证了线程安全;


3.3 锁消除 

锁消除是编译阶段做的一种优化手段,用来检测当前代码是否多线程任务 或者 是否有必要加锁,如果没有必要,又把锁给写了,就会在编译过程中自动把锁去掉;

假如在不涉及线程安全的问题时,我们用 synchronized 关键字对一个操作进行加锁了,那么在编译阶段,就会自动把锁进行一个消除,锁消除这个机制是一个智能化的操作,它会根据不同的代码,去判断当前的操作需不需要进行加锁,如果不需要,就会自动消除;


3.4 锁粗化 

提到锁的粗化,就要先提到一个概念叫锁的粒度,锁的粒度就是 synchronized 代码块里包含代码的多少,一般认为代码越多,粒度越粗,代码越少,粒度越细;

通常写代码的情况下,我们是希望锁的粒度更小一点,因为这样串行的代码少,并发执行的代码就越多;

举个例子:

假设我们给领导打电话汇报3份工作,分两种情况:

1. 先打个电话,汇报 A 的进展,再挂电话

    再打个电话,汇报 B 的进展,再挂电话

    再打个电话,汇报 C 的进展,再挂电话

每次领导接电话就是一个加锁的过程,别人(其他线程)想要给领导打电话就是处于一个阻塞等待的状态,挂电话就是释放锁;当你挂断电话后,再想去汇报工作B的进展,你不一定能打进去,领导可能和别人正在通话,这样一来,你再想打进去就要阻塞等待一会,这个过程就相当于把锁的粒度拆分的更细了,但是每次都可能会阻塞等待,这样效率并不高,还可能并发其他的问题;

2. 打通一次电话,直接把A,B,C的工作进展一次性想领导汇报;

这样就避免了阻塞等待的消耗了,也大大的提升了效率;


3.5 JUC常见组件

JUC是 Java.util.concurrent 的缩写,这里是多线程并发的一个类;

1. Callable接口

这里写一个程序去实现一下这个接口:

public class Demo16 {public static void main(String[] args) throws ExecutionException, InterruptedException {// 创建任务,计算从 1 加到 100 的和Callable<Integer> callable = new Callable<Integer>() {@Overridepublic Integer call() throws Exception {int sum = 0;for (int i = 0; i < 100; i++) {sum += i;}return sum;}};// 创建一个线程来完成这个任务// Thread 不能直接传 callable, 外面需要再包装一层FutureTask<Integer> futureTask = new FutureTask<>(callable);Thread thread = new Thread();thread.start();System.out.println(futureTask.get());}
}

上述代码也是线程创建的一种方法;


2. ReentrantLock类

可重入互斥锁 和 synchronized 定位类似,,都是用来实现互斥效果,,保证线程安全;

但是 synchronzied 是基于代码块实现来控制加锁和解锁,而 ReentrantLock 是提供了 lock 和unlock  的方法来进行加锁和解锁;

虽然 synchronized 已经在绝大多数情况下满足使用了,但是 ReentrantLock 也有自己特殊的方法:

  1. 使用 synchronized 加锁的时候如果发现锁被占有,会进入阻塞状态,而 ReentrantLock 提供了一个 tryLock 方法,如果发现锁被占用,不会阻塞,直接返回 false;
  2.  synchronized 是一个非公平锁(不遵循规则),而 ReentrantLock 提供了公平和非公平两种工作模式;
  3.  synchronized 搭配 wait 和 notify 进行唤醒等待,如果多个线程 wait 同一个对象,notify 的时候随机唤醒一个,而 ReentrantLock 搭配 Condition 这个类进行唤醒等待,并且它能指定一个线程唤醒;

 四. 线程安全的集合类

4.1 HashTable 和 ConcurrentHashMap的区别

我们常用的ArrayList,LinkedList,HashMap等等这样的类都是线程不安全的,那如果我们在多线程环境下要使用,可能就会出问题;

针对这种情况,标准库里提供了线程安全版本的集合类,据我了解,从早期的线程安全的集合说起,它们是 Vector 和 HashTable:

Vector:

Vector 和 ArrayList 类似,是长度可变的数组,与 ArrayList 不同的是,Vector 是线程安全的,它给几乎所有的 public 方法都加上了 synchronized 关键字。由于加锁导致性能降低,在不需要并发访问同一对象时,这种强制性的同步机制就显得多余,所以现在 Vector 已被弃用;

CopyOnWriteArrayList 和 CopyOnWriteArraySet:

它们是加了写锁的 ArrayList 和 ArraySet,锁住的是整个对象,但读操作可以并发执行;

HashTable:

HashTable 和 HashMap类似,不同点是 HashTable 是线程安全的,它给几乎所有 public 方法都加上了 synchronized 关键字,还有一个不同点是 HashTable 的 K,V 都不能是 null ,但 HashMap 可以,它现在也因为性能原因被弃用了;

HashTable 和 ConcurrentHashMap的区别:

HashTable 是针对整个哈希表加锁,任何的 CURD 操作都可能会触发加锁,也可能有锁竞争;而 ConcurrentHashMap 是针对每个链表进行加锁,每次进行操作,都是针对对应链表进行加锁,操作不同链表就是针对不同的锁对象加锁,此时不会有锁冲突,没有锁冲突,就没有阻塞等待,这样也提升了效率;


4.2 多线程相关面试题

(1)谈谈 volatile关键字的用法?

volatile 能够保证内存可见性, 强制从主内存中读取数据,此时如果有其他线程修改被 volatile 修饰的变量,可以第一时间读取到最新的值;
 

(2)Java多线程是如何实现数据共享的?
JVM 把内存分成了这几个区域:方法区,堆区,栈区,程序计数器,其中堆区这个内存区域是多个线程之间共享的,只要把某个数据放到堆内存中, 就可以让多个线程都能访问到;

(3)Java线程共有几种状态?状态之间怎么切换的?

  • NEW: 安排了工作, 还未开始行动. 新创建的线程, 还没有调用 start 方法时处在这个状态;
  • RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作. 调用 start 方法之后, 并正在CPU 上运行/在即将准备运行 的状态;
  • BLOCKED: 使用 synchronized 的时候, 如果锁被其他线程占用, 就会阻塞等待, 从而进入该状态;
  • WAITING: 调用 wait 方法会进入该状态;
  • TIMED_WAITING: 调用 sleep 方法或者 wait(超时时间) 会进入该状态;
  • TERMINATED: 工作完成了. 当线程 run 方法执行完毕后, 会处于这个状态;

(4)Thread和Runnable的区别和联系?

      Thread 类描述了一个线程,Runnable 描述了一个任务,在创建线程的时候需要指定线        程完成的任务, 可以直接重写 Thread 的 run 方法, 也可以使用Runnable 来描述这个任          务;

(5)进程和线程的区别?

  • 进程是包含线程的. 每个进程至少有一个线程存在,即主线程。
  • 进程和进程之间不共享内存空间. 同一个进程的线程之间共享同一个内存空间.
  • 进程是系统分配资源的最小单位,线程是系统调度的最小单位