JavaEE:多线程(进阶)

文章目录

一、常见的锁策略

1、乐观锁 vs 悲观锁

  • 乐观锁:预测接下来锁冲突的概率不大. 做一系列操作.
  • 悲观锁:预测接下来锁冲突的概率很大. 做另一系列操作.

这两种思路不能说谁优谁劣, 而是看当前的场景是否合适.

Synchronized 初始使用乐观锁策略. 当发现锁竞争比较频繁的时候, 就会自动切换成悲观锁策略.

  • 当前锁冲突不大,以乐观锁的方式运行,往往是纯用户态执行的.
  • 一旦发现锁冲突概率大了,以悲观锁的方式运行,往往要进入内核,对当前线程进行挂起等待.

2、读写锁

  • 普通互斥锁:synchronized 就属于普通的互斥锁,两个加锁操作之间会发生竞争.
  • 读写锁:在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥.

一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.

  1. 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
  2. 两个线程都要写一个数据, 有线程安全问题.
  3. 一个线程读另外一个线程写, 也有线程安全问题.

Synchronized 不是读写锁.

3、重量级锁 vs 轻量级锁

  • 重量级锁:锁开销比较大,做的工作比较多. 悲观锁经常会是重量级锁.(不绝对)
  • 轻量级锁:锁开销比较小,做的工作比较少. 乐观锁经常会是轻量级锁.(不绝对)

重量级锁:主要是依赖了 操作系统 提供的 ;使用这种锁,就容易产生阻塞等待.
轻量级锁:主要尽量的避免使用 操作系统 提供的 ;而是尽量在用户态来完成功能,尽量避免 用户态内核态 的切换,尽量避免挂起等待.

在这里插入图片描述
synchronized自适应锁,既是轻量级锁,又是重量级锁.
当冲突程度不高时,是轻量级锁;当冲突程度很高时,是重量级锁.

4、自旋锁(Spin Lock)

  • 自旋锁轻量级锁的具体实现. 既是轻量级锁,也是乐观锁.
  • 挂起等待锁重量级锁的具体实现. 既是重量级锁,也是悲观锁.

自旋锁:当发现锁冲突的时候,不会挂起等待,会迅速再来尝试看这个锁能不能获取到!

自旋锁伪代码:

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

自旋锁:更轻量,乐观锁

  1. 一旦锁被释放,就可以第一时间获取到.
  2. 如果锁一直不释放,就会消耗大量的 CPU.

挂起等待锁:更重量,悲观锁

  1. 一旦锁被释放,就不能第一时间获取到.
  2. 在锁被其他线程占用的时候,会放弃 CPU 资源.

synchronized 作为轻量级锁的时候,内部是自旋锁;作为重量级锁的时候,内部是挂起等待锁.

5、公平锁 vs 非公平锁

假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.
当线程 A 释放锁的时候, 会发生什么呢?

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

注意:操作系统内部对于挂起等待锁,就是非公平的;如果想要使用公平锁,就需要搞额外的数据结构来进行控制实现.

synchronized 是非公平锁.

6、可重入锁 vs 不可重入锁

例1:

package thread;

public class Demo26 {

    private static void func() {
        // 进行一些多线程操作..........
        // 第一次加锁
        synchronized (Demo26.class) {
            // 第二次加锁
            synchronized (Demo26.class) {
                
            }
        }
    }

    public static void main(String[] args) {
        func();
    }
}

对于例1代码:

第一次加锁能够成功,此时 Demo26.class 处于被加锁的状态;
第二次加锁的时候,由于 Demo26.class 已经处于被加锁的状态;
按照之前的理解,这里加锁就会阻塞等待,需要等待第一次加锁释放,第二次加锁才能成功;但是第一次加锁释放需要第二次加锁成功后,执行完才能释放掉,这就形成了一个逻辑上的循环,即死锁.

为了避免上述问题,就引入了 “可重入锁”:一个线程,可以对同一个锁,反复加锁多次!

可重入锁:
在内部记录这个锁是哪个线程获取到的,如果发现当前加锁的线程和持有锁的线程是同一个,则不挂起等待,而是直接获取到锁.
同时还会给锁内部加上一个计数器,记录当前是第几次加锁,只有当计数器为 0 时,才会真正释放锁.

synchronized 是可重入锁.

7、相关面试题

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

  • 乐观锁:预测接下来锁冲突的概率不大. 不会进行加锁,而是在访问的同时识别是否出现访问冲突.
  • 悲观锁:预测接下来锁冲突的概率很大. 每次访问共享变量之前都会进行加锁.

悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.
乐观锁的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突.

② 介绍下读写锁?

读写锁就是把读操作写操作区分对待.

其中,

  • 读加锁读加锁之间, 不互斥.
  • 写加锁写加锁之间, 互斥.
  • 读加锁写加锁之间, 互斥.

读写锁最主要用在 “频繁读, 不频繁写” 的场景中.

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

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

相对于挂起等待锁:

  1. 优点:一旦锁被释放,就可以第一时间获取到. 更加高效,在锁持有时间比较短的场
    景下非常有用.
  2. 缺点:如果锁一直不释放,就会消耗大量的 CPU.

④ synchronized 是可重入锁么?

可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁. 实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数).
如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增.

二、CAS

1、概念

CAS 是操作系统 / 硬件,给 JVM 提供的另外一种 更轻量的 原子操作的机制.

CAS 是 CPU 提供的一个特殊指令:Compare and swap(比较并交换).

下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解 CAS 的工作流程.

例2:CAS 伪代码

// address:内存地址
// expecteValue:用来比较的值(寄存器)
// swapValue:用来交换的值(另一个寄存器)
boolean CAS(address, expecteValue, swapValue) {
	if (&address == expectedValue) {
 		&address = swapValue;
    	return true;
 	}
  	return false;
}

2、CAS 应用

① 实现原子类

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

例3:

package thread;

import java.util.concurrent.atomic.AtomicInteger;

public class Demo27 {
    // private static int count = 0;
    public static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                // count++;
                // 这个方法相当于 count++
                count.getAndIncrement();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                count.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("count = " + count); // 10_0000
    }
}

例4:getAndIncrement 伪代码实现

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

② 实现自旋锁

例5:自旋锁伪代码

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;
 	}
}

当 owner 为 null 的时候,CAS 才能成功,循环结束;
当 owner 为 非null,则说明当前的锁已经被其他线程占用了,就要继续循环(自旋).

3、CAS 的 ABA 问题

① ABA 问题的概念

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

  • 先读取 num 的值, 记录到 oldNum 变量中.
  • 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.

但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A.

我们无法确定,在修改过程中,变量是否发生改变:
在这里插入图片描述

② ABA 问题引来的 BUG

大部分的情况下, t2 线程这样的一个反复横跳改动, 对于 t1 是否修改 num 是没有影响的. 但是不排除一些特殊情况.

假设 我们 有 1000 存款. 想从 ATM 取 500 块钱. 取款机创建了两个线程, 并发的来执行 -50 操作.
正常情况:

  1. 存款 1000. 线程1 获取到当前存款值为 1000, 期望更新为 500; 线程2 获取到当前存款值为 1000, 期望更新为 500.
  2. 线程1 执行扣款成功, 存款被改成 500. 线程2 阻塞等待中.
  3. 轮到线程2 执行了, 发现当前存款为 500, 和之前读到的 1000 不相同, 执行失败.

异常情况:

  1. 存款 1000. 线程1 获取到当前存款值为 1000, 期望更新为 500; 线程2 获取到当前存款值为 1000, 期望更新为 500.
  2. 线程1 执行扣款成功, 存款被改成 500. 线程2 阻塞等待中.
  3. 在线程2 执行之前, 滑稽的朋友正好给滑稽转账 500, 账户余额变成 1000 !!
  4. 轮到线程2 执行了, 发现当前存款为 1000, 和之前读到的 1000 相同, 再次执行扣款操作.

本来只想扣款一次,但是实际上扣款了两次!!!

③ 解决方案

引入了 “版本号” 来解决 ABA 问题!

  1. CAS 操作在读取旧值的同时, 也要读取版本号.
  2. 真正修改的时候,
    如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
    如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).

4、相关面试题

① 讲解下你自己理解的 CAS 机制

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

② ABA问题怎么解决?

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

三、Synchronized 原理

1、基本特点

synchronized 使用的所策略:

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

2、加锁工作过程

synchronized 在加锁的时候要经历几个阶段:
无锁(没加锁)
偏向锁(刚开始加锁,未产生竞争的时候)
轻量级锁(产生锁竞争了)
重量级锁(锁竞争的更激烈了)

① 偏向锁

偏向锁,不是 “真正加锁”,只是用个标记表示 “这个锁是我的了”.
在遇到其他线程来竞争之前,都会保持这个状态.
直到真的有人来竞争了,此时才真正的加锁.

这个过程类似于单例模式 “懒汉模式”,必要的时候再加锁,节省开销.

② 轻量级锁

随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).
此处的轻量级锁就是通过 CAS 来实现.

  • 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
  • 如果更新成功, 则认为加锁成功
  • 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).

③ 重量级锁

如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁

此处的重量级锁就是指用到内核提供的 mutex .

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

3、其他的优化操作

① 锁消除

锁消除:编译器自动判定,如果认为这个代码没必要加锁,就不加了.

例6:

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

此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销.

② 锁粗化

锁的粒度:synchronized 包含的代码范围是大还是小.
范围越大,粒度约粗;范围约小,粒度约细.

举例:

滑稽老哥当了领导, 给下属交代工作任务:
方式一:

  • 打电话, 交代任务1, 挂电话.
  • 打电话, 交代任务2, 挂电话.
  • 打电话, 交代任务3, 挂电话.

方式二:

  • 打电话, 交代任务1, 任务2, 任务3, 挂电话.

显然, 方式二是更高效的方案.

4、相关面试题

① 什么是偏向锁?

偏向锁,不是 “真正加锁”,只是用个标记表示 “这个锁是我的了”.
在遇到其他线程来竞争之前,都会保持这个状态.
直到真的有人来竞争了,此时才真正的加锁.

随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).

② synchronized 实现原理 是什么?

参考该章节全部内容!

四、Callable 接口

1、Callable 的用法

和 Runnable 非常相似.都是可以在创建线程的时候,来指定一个 “具体的任务”.

  • Callable 指定的任务是带返回值的.
  • Runnable 则不带返回值.

例7:创建线程计算 1 + 2 + 3 + … + 1000

package thread;

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class Demo28 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int sum = 0;
                for (int i = 1; i < 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };
        // 套上一层,目的是为了获取到后续的结果.
        FutureTask<Integer> task = new FutureTask<Integer>(callable);
        Thread t = new Thread(task);
        t.start();

        // 在线程 t 执行结束之前,get 会阻塞等待,直到 t 执行完了,结果算好了.
        // get 才能返回,返回值是 call 方法 return 的内容.
        System.out.println(task.get()); 		// 499500
    }
}

2、相关面试题

介绍下 Callable 是什么

Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算结果.
Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务, Runnable 描述的是不带返回值的任务.
Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为 Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的工作.

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

1、ReentrantLock

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

ReentrantLock 和 synchronized 的区别:

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

例8:

package thread;

import java.util.concurrent.locks.ReentrantLock;

public class Demo29 {
    public static void main(String[] args) {
        ReentrantLock locker = new ReentrantLock();
        try {
            // 加锁
            // locker.lock();
            locker.tryLock();  // 加锁失败,不会死等. 

            // 代码...... 如果中间抛出异常了,就可能执行不到 unlock
        } finally {
            // 解锁
            locker.unlock();
        }
    }
}

如何选择使用哪个锁?

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

2、原子类

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。原子类有以下几个

  • AtomicBoolean
  • AtomicInteger
  • AtomicIntegerArray
  • AtomicLong
  • AtomicReference
  • AtomicStampedReference

以 AtomicInteger 举例,常见方法有

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

3、线程池

① ExecutorService 和 Executors

  • ExecutorService 表示一个线程池实例.
  • Executors 是一个工厂类, 能够创建出几种不同风格的线程池.
  • ExecutorService 的 submit 方法能够向线程池中提交若干个任务.

例9:

package thread;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Demo24 {
    public static void main(String[] args) {
        ExecutorService threadPool = Executors.newFixedThreadPool(10);
        // Executors.newCachedThreadPool();

        threadPool.submit(new Runnable() {
            @Override
            public void run() {
                System.out.println("hello");
            }
        });
    }
}

Executors 创建线程池的几种方式

  • newFixedThreadPool: 创建固定线程数的线程池
  • newCachedThreadPool: 创建线程数目动态增长的线程池.
  • newSingleThreadExecutor: 创建只包含单个线程的线程池.
  • newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.

Executors 本质上是 ThreadPoolExecutor 类的封装.

② ThreadPoolExecutor

在这里插入图片描述

理解 ThreadPoolExecutor 构造方法的参数
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.

  • corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
  • maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
  • keepAliveTime: 临时工允许的空闲时间. 超过这个时间,线程就会被销毁
  • unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
  • workQueue: 传递任务的阻塞队列. (体现出了线程池的扩展性)
  • threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
  • RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
    • AbortPolicy(): 超过负荷, 直接抛出异常.
    • CallerRunsPolicy(): 调用者负责处理.
    • DiscardOldestPolicy(): 丢弃队列中最老的任务.
    • DiscardPolicy(): 丢弃新来的任务.

③ 线程池的工作流程

在这里插入图片描述

4、信号量 Semaphore

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

可用把信号量视为一个更加广义的锁,当信号量的取值为 0 ~ 1 的时候,就退化成了一个普通的锁.

  • 申请一个可用资源,信号量 -= 1,称为 P 操作.
  • 释放一个可用资源,信号量 += 1,称为 V 操作.

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

例10:

package thread;

import java.util.concurrent.Semaphore;

public class Demo30 {
    public static void main(String[] args) throws InterruptedException {
        // 构造方法传入有效资源的个数
        Semaphore semaphore=new Semaphore(3);

        // P 操作 申请资源
        semaphore.acquire();
        System.out.println("申请资源");     // 打印
        semaphore.acquire();
        System.out.println("申请资源");     // 打印
        semaphore.acquire();
        System.out.println("申请资源");     // 打印
        semaphore.acquire();
        System.out.println("申请资源");     // 没有资源,无法打印
        // V 操作 释放资源
        // semaphore.release();
    }
}

5、CountDownLatch

相当于,在一个大任务被拆分为若干的子任务的时候,用这个来衡量什么时候这些子任务都执行结束.

例如:进行一次跑步比赛,CountDownLatch 描述什么时候所有人都通过终点.

  • 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.
  • 每个任务执行完毕, 都调用 latch . countDown () . 在 CountDownLatch 内部的计数器同时自减.
  • 主线程中使用 latch . await () ; 阻塞等待所有任务执行完毕. 相当于计数器为 0 了.

例11:

package thread;

import java.util.concurrent.CountDownLatch;

public class Demo31 {
    public static void main(String[] args) throws InterruptedException {
        // 模拟跑步比赛
        // 构造方法中设定有几个选手参赛
        CountDownLatch latch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            Thread t = new Thread(() -> {
                try {
                    Thread.sleep(3000);
                    System.out.println(finalI + "号选手到达终点");
                    // countDown 相当于 “撞线”
                    latch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            t.start();
        }
        // await 在等待所有的线程 “撞线”
        // 即调用 countDown 的次数达到初始化的时候设定的值
        // await 就返回. 否则 await 就阻塞等待!
        latch.await();

        System.out.println("比赛结束!");
    }
}

6、相关面试题

① 线程同步的方式有哪些?

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

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

以 juc 的 ReentrantLock 为例,

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

③ AtomicInteger 的实现原理是什么?

参考 CAS - CAS 应用 - 实现原子类 - 例4:

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

④ 信号量听说过么?之前都用在过哪些场景下?

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

⑤ 解释一下 ThreadPoolExecutor 构造方法的参数的含义

参考 ThreadPoolExecutor 章节

理解 ThreadPoolExecutor 构造方法的参数
把创建一个线程池想象成开个公司. 每个员工相当于一个线程.

  • corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
  • maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
  • keepAliveTime: 临时工允许的空闲时间. 超过这个时间,线程就会被销毁
  • unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
  • workQueue: 传递任务的阻塞队列. (体现出了线程池的扩展性)
  • threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
  • RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.
    • AbortPolicy(): 超过负荷, 直接抛出异常.
    • CallerRunsPolicy(): 调用者负责处理.
    • DiscardOldestPolicy(): 丢弃队列中最老的任务.
    • DiscardPolicy(): 丢弃新来的任务.

六、线程安全的集合类

  • 线程安全:Vector,Stack(Stack 继承自 Vector 实现的),HashTable.
  • 线程不安全:ArrayList,LinkeList,TreeSet,TreeMap,HashSet,HashMap,Queue.

1、多线程环境使用 ArrayList

① 自己使用同步机制 (synchronized 或者 ReentrantLock)

前面做过很多相关的讨论了. 此处不再展开.

② Collections.synchronizedList(new ArrayList);

synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
synchronizedList 的关键操作上都带有 synchronized

③ 使用 CopyOnWriteArrayList

CopyOnWriteArrayList:如果出现修改操作,就把 ArrayList 进行复制.
先拷贝一份数据,,新线程修改副本,再用副本替换原有的数据. 这样做的好处是我们可以对 CopyOnWrite
容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。 所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

优点:

  • 在读多写少的场景下, 性能很高, 不需要加锁竞争.

缺点:

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

2、多线程环境使用队列

① ArrayBlockingQueue

基于数组实现的阻塞队列

② LinkedBlockingQueue

基于链表实现的阻塞队列

③ PriorityBlockingQueue

基于堆实现的带优先级的阻塞队列

④ TransferQueue

最多只包含一个元素的阻塞队列

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

HashMap 本身不是线程安全的.

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

  • HashTable
  • ConcurrentHashMap

① HashTable

HashTable 保证线程安全,直接 synchronized !!!

这相当于直接针对 HashTable 对象本身加锁.

  • 如果多个线程要操作同一个 HashTable 就会直接造成锁冲突.

② ConcurrentHashMap

相比于 Hashtable 做出了一系列的改进和优化(Java 8).

  1. 把锁的粒度细化了,加锁的方式仍然是用 synchronized, 但是不是锁整个对象, 而是 “锁桶”(每个链表的头结点);桶 的数量越多,出现锁冲突的概率越低.
  2. 读操作不进行加锁,只针对写操作加锁.
  3. 更充分的使用了 CAS 特性,更高效的操作,比如 size 属性通过 CAS 来更新.
  4. 针对扩容场景进行了优化:化整为零,每次基本操作,都扩容一点点,逐渐完成整个扩容
    • 发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.
    • 扩容期间, 新老数组同时存在.
    • 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小
      部分元素.
    • 搬完最后一个元素再把老数组删掉.
    • 这个期间, 插入只往新数组加.
    • 这个期间, 查找需要同时查新数组和老数组

③ HashMap,HashTable, ConcurrentHash 的区别

4、相关面试题

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

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

② 介绍下 ConcurrentHashMap的锁分段技术?

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

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

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

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

很多同学会说:HashMay key允许为 null,另外两个不允许为 null
这个是区别,但是不是重要的区别!

应该从线程安全上开始:

  1. HashMap 线程不安全,HashTable 和 ConcurrentHash 线程安全.
  2. HashTable 是一把大锁,锁冲突的概率很高;ConcurrentHash 则是每个哈希桶一把锁,锁冲突的概率大大降低.
  3. 然后再说 ConcurrentHash 使用了 CAS 特性,以及扩容优化.
  4. 最后再说 HashMay key允许为 null,另外两个不允许为 null.

七、死锁

1、概念

死锁是多线程代码中的常见 BUG!

尝试加锁的时候发现上次锁没有及时释放(因为一些原因,BUG),导致加锁加不上.

造成死锁的原因:

  1. 一个线程一把锁

    • 线程 A 针对锁 1 连续加锁两次,如果锁 1 是不可重入锁,就死锁了.
  2. 两个线程两把锁

    • 线程 A 获取到锁 1,线程 B 获取到锁 2;线程 A 尝试获取锁 2,线程 B 尝试获取锁1,就死锁了.
  3. N 个线程 M 把锁

    • 著名的 “哲学家就餐问题”:有个桌子, 围着一圈 哲学家, 桌子中间放着一盘意大利面. 每个哲学家两两之间, 放着一根筷子.每个 哲学家 只做两件事: 思考人生 或者 吃面条. 思考人生的时候就会放下筷子. 吃面条就会拿起左右两边的筷子(先拿起左边, 再拿起右边). 如果 哲学家 发现筷子拿不起来了(被别人占用了), 就会阻塞等待. 假设同一时刻, 五个 哲学家 同时拿起左手边的筷子, 然后再尝试拿右手的筷子, 就会发现右手的筷子都被占用了. 由于 哲学家 们互不相让, 这个时候就形成了 死锁.

2、如何避免死锁

死锁的四个必要条件:

  1. 互斥使用:线程 1 拿到 锁 A,其他线程无法获取到 A.
  2. 不可抢占:线程 1 拿到 锁 A,其他线程只能阻塞等待,等待 线程 1 主动释放锁,而不是强行把锁抢走.
  3. 请求和等待:当 线程 1 拿到 锁 A 后,就会一直持有这个获取到锁的状态,直到主动释放.
  4. 循环等待:线程 1 等待 线程 2,线程2 又尝试等待 线程 1.

其中,前三条是在描述锁的基本特点;第四条和代码编写,密切相关,是可以通过注意解决的!

破坏循环等待

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

3、相关面试题

① 谈谈死锁是什么,如何避免死锁,避免算法? 实际解决过没有?

参考整个 “死锁” 章节
被问到什么是死锁,起手式别搞错了,千万不要上来就说 “死锁的四个必要条件”!
流程:

  1. 概况死锁的概念
  2. 产生死锁的三个典型常见
  3. 死锁的必要条件(一定要说出来 循环等待
  4. 从 循环等待 的角度切入,对锁编号,并按顺序加锁,破坏循环等待.

八、其他常见面试题

1、谈谈 volatile关键字的用法?

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

2、Java多线程是如何实现数据共享的?

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

3、Java创建线程池的接口是什么?参数 LinkedBlockingQueue 的作用是什么?

创建线程池主要有两种方式:

  • 通过 Executors 工厂类创建. 创建方式比较简单, 但是定制能力有限.
  • 通过 ThreadPoolExecutor 创建. 创建方式比较复杂, 但是定制能力强.

LinkedBlockingQueue 表示线程池的任务队列. 用户通过 submit / execute 向这个任务队列中添加任务, 再由线程池中的工作线程来执行任务.

4、Java线程共有几种状态?状态之间怎么切换的?

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

5、在多线程下,如果对一个数进行叠加,该怎么做?

  • 使用 synchronized / ReentrantLock 加锁
  • 使用 AtomInteger 原子操作.

6、Servlet是否是线程安全的?

Servlet 本身是工作在多线程环境下.
如果在 Servlet 中创建了某个成员变量, 此时如果有多个请求到达服务器, 服务器就会多线程进行操作, 是可能出现线程不安全的情况的.

7、Thread和Runnable的区别和联系?

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

8、多次start一个线程会怎么样

第一次调用 start 可以成功调用.
后续再调用 start 会抛出 java.lang.IllegalThreadStateException 异常.

9、有synchronized两个方法,两个线程分别同时用这个方法,请问会发生什么?

synchronized 加在非静态方法上, 相当于针对当前对象加锁.
如果这两个方法属于同一个实例:

  • 线程1 能够获取到锁, 并执行方法. 线程2 会阻塞等待, 直到线程1 执行完毕, 释放锁, 线程2 获取到锁之后才能执行方法内容.

如果这两个方法属于不同实例:

  • 两者能并发执行, 互不干扰.

10、进程和线程的区别?

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

WE-ubytt

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值