【Java多线程】线程安全笔记(二)

本文将会介绍Java多线程中的重点知识,本文内容参考了网上的资料整理,主要为了自己看着方便,方便查找。
主要来源有:

  1. Guide哥
  2. 小林Coding
  3. 菜鸟教程

线程安全

  • 一、锁策略
    • 1、乐观锁&悲观锁
      • 1.1乐观锁
      • 1.2悲观锁
      • 1.3 总结
    • 2、互斥锁&自旋锁
      • 2.1 互斥锁
      • 2.2 自旋锁
    • 3、轻量级锁&重量级锁
      • 3.1 重量级锁
      • 3.2 轻量级锁
    • 4、读写锁
    • 5、公平锁&非公平锁
    • 6、可重入锁&不可重入锁
  • 二、CAS
    • 1、CAS的应用
      • 1.1 实现原子类
      • 1.2 实现自旋锁
    • 2、CAS的ABA问题
      • 2.1 ABA问题
    • 3、CAS 机制的理解
    • 4、ABA问题怎么解决?
  • 三、Synchronized 原理
    • 1、基本特性
    • 2、加锁过程
      • 2.1 偏向锁
      • 2.2 轻量级锁
      • 2.3 重量级锁
    • 3、优化操作
      • 3.1 锁消除
      • 3.2 锁粗化
  • 四、JUC(java.util.concurrent) 的常见类
    • 1、ReentrantLock
    • 2、ReentrantLock 和 Synchronized 的区别
    • 3、Atomic 原子类
    • 4、信号量
    • 5、CountDownLatch
  • 五、线程安全的集合类
    • 1、多线程环境使用 ArrayList
    • 2、多线程环境使用队列
    • 3、多线程环境使用哈希表
      • 3.1 Hashtable
      • 3.2 ConcurrentHashMap
  • 六、死锁
    • 1、认识死锁
    • 2、如何预防和避免线程死锁?
      • 2.1 如何预防死锁?
      • 2.2 如何避免死锁?
  • 七、一些相关的问题
    • 1. 线程同步的方式有哪些?
    • 2、为什么有了 synchronized 还需要 juc 下的 lock?
    • 3、信号量都用在哪些场景下?
    • 4、ConcurrentHashMap的读是否要加锁,为什么?
    • 5、 ConcurrentHashMap的锁分段技术?
    • 6、ConcurrentHashMap在jdk1.8做了哪些优化?
    • 7、Hashtable和HashMap、ConcurrentHashMap 之间的区别?

一、锁策略


锁策略是操作系统上的一个重要策略,不仅仅局限于Java。
Synchronized 最开始使用的就是乐观锁策略。当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略。

1、乐观锁&悲观锁

1.1乐观锁

  • 乐观锁做事比较乐观,它假定冲突的概率很低
  • 它的工作方式是: 先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现乐观锁全程并没有加锁,所以它也叫无锁编程。

乐观锁的一个重要功能就是要检测出数据是否发生访问冲突。我们可以引入一个 “版本号” 来解决。
使用版本号,对每一次修改都记录在案,只要发生修改则版本号+1。提交的版本必须大于当前版本才能执行更新。

1.2悲观锁

悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。

1.3 总结

乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。

2、互斥锁&自旋锁

锁的最低层就是互斥锁自旋锁,很多高级的锁都是基于它俩实现的。
加锁的目的就是保证共享资源在任意时间里,只有一个线程访问,这样就可以避免多线程导致共享数据错乱的问题。
当已经有一个线程加锁后,其他线程加锁则就会失败,互斥锁和自旋锁对于加锁失败后的处理方式是不一样的:

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

2.1 互斥锁

互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
**对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。**当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。
互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。

那这个开销成本是什么呢?会有两次线程上下文切换的成本:

  • 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行
  • 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行

综上,如果被锁住的代码执行时间很短,那么就不应该使用互斥锁,应该选择自旋锁。

2.2 自旋锁

自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。

自旋锁伪代码:

while (抢锁(lock) == 失败) {}

一般加锁的过程,包含两个步骤:

  1. 查看锁的状态,如果锁是空闲的,则执行2;
  2. 将锁设置为当前线程持有;
    **CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,**这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。
    使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。
    自旋锁比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。

自旋锁是一种典型的轻量级锁的实现方式:

  • 优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁。
  • 缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源。(而挂起等待的时候是
    不消耗 CPU 的)。

synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的

3、轻量级锁&重量级锁

锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的.

  • CPU 提供了 “原子操作指令”。
  • 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁。
  • JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类。

3.1 重量级锁

加锁机制重度依赖了 OS 提供了 mutex

  • 大量的内核态用户态切换
  • 很容易引发线程的调度

3.2 轻量级锁

加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex。

  • 少量的内核态用户态切换。
  • 不太容易引发线程调度。

4、读写锁

读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。
读写锁适用于能明确区分读操作和写操作的场景。

读写锁的工作原理是:

  • 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
  • 一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。

所以,写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁,而读锁是共享锁,因为读锁可以被多个线程同时持有。
综上,读写锁非常适合读多写少的场景

读写锁就是把读操作和写操作区分对待。Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写
锁。

  • ReentrantReadWriteLock.ReadLock 类表示一个读锁。 这个对象提供了 lock/unlock 方法进行
    加锁解锁。
  • ReentrantReadWriteLock.WriteLock 类表示一个写锁。 这个对象也提供了 lock/unlock 方法进
    行加锁解锁。

5、公平锁&非公平锁

公平锁: 遵守 “先来后到”. B 比 C 先来的。 当 A 释放锁的之后, B 就能先于 C 获取到锁。
非公平锁: 不遵守 “先来后到”。 B 和 C 都有可能获取到锁。

操作系统内部的线程调度就可以视为是随机的。如果不做任何额外的限制, 锁就是非公平锁。如果要
想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序。
**注:**公平锁和非公平锁没有好坏之分, 关键还是看适用场景。Synchronized 是非公平锁。

6、可重入锁&不可重入锁

可重入锁的字面意思是“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
比如一个递归函数里有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,那么这个锁就是可重入
锁(因为这个原因可重入锁也叫做递归锁)。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括
synchronized关键字锁都是可重入的

Linux 系统提供的 mutex 是不可重入锁。
注:Synchronized 是可重入锁。

二、CAS


CAS: 全称Compare and swap,字面意思:”比较并交换“。一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

  1. 比较 A 与 V 是否相等。(比较)
  2. 如果比较相等,将 B 写入 V。(交换)
  3. 返回操作是否成功。

CAS的操作是一个原子的硬件执行完成的。

1、CAS的应用

1.1 实现原子类

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的。
典型的就是 AtomicInteger 类。其中的 getAndIncrement 相当于 i++操作。

AtomicInteger atomicInteger = new AtomicInteger(0);
// 相当于 i++
atomicInteger.getAndIncrement();

伪代码:

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
       }
        return oldValue;
   }
}

假设两个线程同时调用 getAndIncrement

  1. 两个线程都读取 value 的值到 oldValue 中。 (oldValue 是一个局部变量, 在栈上。 每个线程有自己的
    栈)
  2. 线程1 先执行 CAS 操作。由于 oldValue 和 value 的值相同, 直接进行对 value 赋值。
    【Java多线程】线程安全笔记(二)_第1张图片
  3. 线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值。 因此需
    要进入循环。在循环里重新读取 value 的值赋给 oldValue。
  4. 线程2 接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作。
  5. 线程1 和 线程2 返回各自的 oldValue 的值即可。

1.2 实现自旋锁

自旋锁伪代码

public class SpinLock {
    private Thread owner = null;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有. 
        // 如果这个锁已经被别的线程持有, 那么就自旋等待. 
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 
        while(!CAS(this.owner, null, Thread.currentThread())){
       }
   }
    public void unlock (){
        this.owner = null;
   }
}

2、CAS的ABA问题

2.1 ABA问题

假设存在两个线程t1和t2。有一个共享变量num。初始值为A。
接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要

  • 先读取 num 的值, 记录到 oldNum 变量中
  • 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z
    但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
    线程 t1 的 CAS 是期望 num 不变就修改。但是 num 的值已经被 t2 给改了。只不过又改成 A 了。这
    个时候 t1 究竟是否要更新 num 的值为 Z 呢?
    t1 线程无法区分当前这个变量始终是 A, 还是经历了一个变化过程
    示意图:
    【Java多线程】线程安全笔记(二)_第2张图片
    引入版本号可以解决该问题,做的每次一修改都会被版本号记录在案。

3、CAS 机制的理解

全称 Compare and swap, 即 “比较并交换”. 相当于通过一个原子的操作, 同时完成 “读取内存, 比
较是否相等, 修改内存”
这三个步骤. 本质上需要 CPU 指令的支撑。

4、ABA问题怎么解决?

给要修改的数据引入版本号。在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期。
如果发现当前版本号和之前读到的版本号一致, 就真正执行修改操作, 并让版本号自增; 如果发现当
前版本号比之前读到的版本号大, 就认为操作失败。

三、Synchronized 原理


了解了锁之后,了解一下Synchronized关键字的原理。

1、基本特性

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

2、加锁过程

JVM的锁分为以下四种,会根据情况依次升级。
【Java多线程】线程安全笔记(二)_第3张图片

2.1 偏向锁

第一个尝试加锁的线程, 优先进入偏向锁状态。

  • 偏向锁不是真的加锁,只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程。
  • 偏向锁本质上相当于 “延迟加锁” 。能不加锁就不加锁, 尽量来避免不必要的加锁开销。

2.2 轻量级锁

随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁)。
此处的轻量级锁就是通过 CAS 来实现。
注: 此处的自旋不会一直持续进行,一直让 CPU 空转, 比较浪费 CPU 资源。因此达到一定的时间/重试次数, 就不再自旋了。也就是所谓的 “自适应”。

2.3 重量级锁

如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会转变为重量级锁。
此处的重量级锁就是指用到内核提供的 mutex

  • 执行加锁操作, 先进入内核态
  • 在内核态判定当前锁是否已经被占用
  • 如果该锁没有占用, 则加锁成功, 并切换回用户态
  • 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒
  • 经历了很长时间,这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁

3、优化操作

3.1 锁消除

通过编译器+JVM 判断锁是否可消除。如果可以, 就直接消除。

有些程序代码中用到了 synchronized关键字,但是在单线程情况下就会进行锁消除,减少没用的资源开销。
示例:

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

3.2 锁粗化

一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化。
【Java多线程】线程安全笔记(二)_第4张图片

使用细粒度锁, 是期望释放锁的时候其他线程能使用锁。但是实际上可能并没有其他线程来抢占这个锁。 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁。

四、JUC(java.util.concurrent) 的常见类

1、ReentrantLock

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

ReentrantLock 的用法:

方法 作用
lock() 加锁, 如果获取不到锁就死等
trylock(超时时间) 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁
unlock() 解锁

2、ReentrantLock 和 Synchronized 的区别

  1. Synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现);ReentrantLock 是标准
    库的一个类, 在 JVM 外实现的(基于 Java 实现)。
  2. Synchronized 使用时不需要手动释放锁; ReentrantLock 使用时需要手动释放。 使用起来更灵活, 但
    是也容易遗漏 unlock。
  3. Synchronized 在申请锁失败时, 会死等。 ReentrantLock 可以通过 trylock 的方式等待一段时间就放
    弃。
  4. Synchronized 和ReentrantLock默认都是非公平锁。可以通过构造方法传入一个 true 开启。
    公平锁模式。
  5. 更强大的唤醒机制。Synchronized 是通过 Object 的 wait / notify 实现等待-唤醒。 每次唤醒的是一
    个随机等待的线程。 ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指
    定的线程。

如何选择使用哪个锁?

  • 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便。
  • 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等。
  • 如果需要使用公平锁, 使用 ReentrantLock。

3、Atomic 原子类

Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其
他线程干扰。所以,所谓原子类说简单点就是具有原子/原子操作特征的类。

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。
并发包 java.util.concurrent 的原子类都存放在java.util.concurrent.atomic下:
【Java多线程】线程安全笔记(二)_第5张图片
根据操作的数据类型,可以将 JUC 包中的原子类分为 4 类:

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

说明
AtomicInteger 整型原子类
AtomicLong 长整型原子类
AtomicBoolean 布尔型原子类

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

说明
AtomicIntegerArray 整型数组原子类
AtomicLongArray 长整型数组原子类
AtomicReferenceArray 引用类型数组原子类

引用类型

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

对象的属性修改类型

说明
AtomicIntegerFieldUpdater 原子更新整型字段的更新器
AtomicLongFieldUpdater 原子更新长整型字段的更新器
AtomicReferenceFieldUpdater 原子更新引用类型里的字段

以 AtomicInteger 举例,常见方法有:

addAndGet(int delta);   i += delta;
decrementAndGet(); 		--i;
getAndDecrement(); 		i--;
incrementAndGet();		++i;
getAndIncrement(); 		i++;

常用方法的使用:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicIntegerTest {

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		int temvalue = 0;
		AtomicInteger i = new AtomicInteger(0);
		temvalue = i.getAndSet(3);
		System.out.println("temvalue:" + temvalue + ";  i:" + i);//temvalue:0;  i:3
		temvalue = i.getAndIncrement();
		System.out.println("temvalue:" + temvalue + ";  i:" + i);//temvalue:3;  i:4
		temvalue = i.getAndAdd(5);
		System.out.println("temvalue:" + temvalue + ";  i:" + i);//temvalue:4;  i:9
	}

}

4、信号量

信号量,本质上就是一个计数器,用来表示 “可用资源的个数”。

Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用。

代码示例

  • 创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源。
  • acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)。
  • 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源。 观察程序的执行效果。
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        try {
            System.out.println("申请资源");
            semaphore.acquire();
            System.out.println("我获取到资源了");
            Thread.sleep(1000);
            System.out.println("我释放资源了");
            semaphore.release();
       } catch (InterruptedException e) {
            e.printStackTrace();
       }
   }
};
for (int i = 0; i < 20; i++) {
    Thread t = new Thread(runnable);
    t.start();
}

5、CountDownLatch

同时等待 N 个任务执行结束。

  • 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成。
  • 每个任务执行完毕, 都调用 latch.countDown()。 在 CountDownLatch 内部的计数器同时自减。
  • 主线程中使用 latch.await(); 阻塞等待所有任务执行完毕。 相当于计数器为 0 了。
public class Demo {
    public static void main(String[] args) throws Exception {
        CountDownLatch latch = new CountDownLatch(10);
        Runnable r = new Runable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(Math.random() * 10000);
                    latch.countDown();
               } catch (Exception e) {
                    e.printStackTrace();
               }
           }
       };
        for (int i = 0; i < 10; i++) {
            new Thread(r).start();
       }
  		 // 必须等到 10 人全部回来
        latch.await();
        System.out.println("比赛结束");
   }
}

五、线程安全的集合类


原来的集合类, 大部分都不是线程安全的。

1、多线程环境使用 ArrayList

  1. 自己使用同步机制 (synchronized 或者 ReentrantLock)
  2. Collections.synchronizedList(new ArrayList)
  3. 使用 CopyOnWriteArrayList

优点:
在读多写少的场景下, 性能很高, 不需要加锁竞争。
缺点:

  • 占用内存较多。
  • 新写的数据不能被第一时间读取到。

2、多线程环境使用队列

方法 说明
ArrayBlockingQueue 基于数组实现的阻塞队列
LinkedBlockingQueue 基于链表实现的阻塞队列
PriorityBlockingQueue 基于实现的带优先级的阻塞队列
TransferQueue 最多只包含一个元素的阻塞队列

3、多线程环境使用哈希表

在多线程环境下使用哈希表可以使用:

  • Hashtable
  • ConcurrentHashMap

3.1 Hashtable

把关键方法加上了 synchronized 关键字

相当于直接针对 Hashtable 对象本身加锁。

  • 如果多线程访问同一个 Hashtable 就会直接造成锁冲突。
  • size 属性也是通过 synchronized 来控制同步, 也是比较慢的。
  • 一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低。

3.2 ConcurrentHashMap

相比于 Hashtable 做出了一系列的改进和优化

  • 读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁. 加锁的方式仍然是使用 Synchronized, 但是不是锁整个对象, 而是 “锁桶” (用每个链表的头结点作为锁对象), 大大降低了锁冲突的概率。
  • 充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况。
  • 优化了扩容方式: 化整为零
    • 发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去。
    • 扩容期间, 新老数组同时存在。
    • 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小部分元素。
    • 搬完最后一个元素再把老数组删掉。
    • 这个期间, 插入只往新数组加;查找需要同时查新数组和老数组。

六、死锁


1、认识死锁

线程死锁描述的是这样一种情况:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。
如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。
【Java多线程】线程安全笔记(二)_第6张图片
有名的就是哲学家就餐问题等。

产生死锁的四个必要条件:

  • 互斥条件: 该资源任意一个时刻只由一个线程占用。
  • 请求与保持条件: 一个线程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件: 线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。
  • 循环等待条件: 若干线程之间形成一种头尾相接的循环等待资源关系。

**注:**当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让死锁消失。

2、如何预防和避免线程死锁?

2.1 如何预防死锁?

破坏死锁的产生的必要条件即可:

  • 破坏请求与保持条件 : 一次性申请所有的资源。
  • 破坏不剥夺条件 : 占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
  • 破坏循环等待条件 : 靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

示例:
破坏循环等待。最常用的一种死锁阻止技术就是锁排序。假设有 N 个线程尝试获取 M 把锁, 就可以针对 M 把锁进行编号(1, 2, 3…M)。
N 个线程尝试获取锁的时候, 都按照固定的按编号由小到大顺序来获取锁。这样就可以避免环路等待。

可能产生环路等待的代码:两个线程对于加锁的顺序没有约定, 就容易产生环路等待。

Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
    @Override
    public void run() {
        synchronized (lock1) {
            synchronized (lock2) {
                // do something...
           }
       }
   }
};
t1.start();
Thread t2 = new Thread() {
    @Override
    public void run() {
        synchronized (lock2) {
            synchronized (lock1) {
                // do something...
           }
       }
   }
};
t2.start();

不会产生环路等待的代码:约定好先获取 lock1, 再获取 lock2 , 就不会环路等待。

Object lock1 = new Object();
Object lock2 = new Object();
Thread t1 = new Thread() {
    @Override
    public void run() {
        synchronized (lock1) {
            synchronized (lock2) {
                // do something...
           }
       }
   }
};
t1.start();
Thread t2 = new Thread() {
    @Override
    public void run() {
        synchronized (lock1) {
            synchronized (lock2) {
                // do something...
           }
       }
   }
};
t2.start();

2.2 如何避免死锁?

避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。

安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3…Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称序列为安全序列。

七、一些相关的问题


1. 线程同步的方式有哪些?

synchronized, ReentrantLock, Semaphore 等都可以用于线程同步。

2、为什么有了 synchronized 还需要 juc 下的 lock?

以 juc 的 ReentrantLock 为例,

  • synchronized 使用时不需要手动释放锁。 ReentrantLock 使用时需要手动释放. 使用起来更灵
    活。
  • synchronized 在申请锁失败时, 会死等。 ReentrantLock 可以通过 trylock 的方式等待一段时
    间就放弃。
  • synchronized 是非公平锁, ReentrantLock 默认是非公平锁。可以通过构造方法传入一个
    true 开启公平锁模式。
  • synchronized 是通过 Object 的 wait / notify 实现等待-唤醒。每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程。

3、信号量都用在哪些场景下?

使用信号量可以实现 “共享锁”, 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待, 直到前面的线程执行了 V 操作。

4、ConcurrentHashMap的读是否要加锁,为什么?

读操作没有加锁. 目的是为了进一步降低锁冲突的概率. 为了保证读到刚修改的数据, 搭配了volatile 关键字。

5、 ConcurrentHashMap的锁分段技术?

Java1.7 中采取的技术. Java1.8 中已经不再使用了. 简单的说就是把若干个哈希桶分成一个"段" (Segment), 针对每个段分别加锁。
目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争。

6、ConcurrentHashMap在jdk1.8做了哪些优化?

取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象)。
将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于 8 个元素)就转换成红黑树。

7、Hashtable和HashMap、ConcurrentHashMap 之间的区别?

  • HashMap: 线程不安全。key 允许为 null。
  • Hashtable: 线程安全。 使用 synchronized 锁 Hashtable 对象, 效率较低. key 不允许为 null。
  • ConcurrentHashMap: 线程安全。 使用 synchronized 锁每个链表头结点, 锁冲突概率低, 充分利用CAS 机制。优化了扩容方式. key 不允许为 null。

你可能感兴趣的:(Java进阶,java,jvm,开发语言)