多线程进阶(常见面试题)

一、常见的锁策略

锁策略,指的是解决问题的具体思路。加锁是一个开销比较大的事情,我们希望在一些特定的场景下,针对场景做出一些取舍,能够让锁的获取和使用更高效一些。

这些锁不仅仅局限于Java中,其它语言也涉及到加锁,也可以应用这些策略。这些锁策略也不一定在synchronized中体现。

1. 乐观锁和悲观锁

乐观锁:假设发生锁冲突概率比较小、基本上没有冲突的情况下,因此就直接尝试访问数据,直到出现了锁冲突后再简单地去处理问题。
悲观锁:假设发生锁冲突的概率比较高,悲观锁会付出更高的成本先去处理问题,然后再去尝试访问数据。

咱们所说的synchronized其实就是以悲观锁为主,但也不全是,看情况下它会变为乐观锁

乐观锁的一个重要的功能就是能够自动检测出数据是否会访问冲突,可以引入一个“版本号”来解决。

假设我们需要多线程修改 “用户账户余额”,设当前余额为 100. 引入一个版本号 version, 初始值为 1. 并且我们规定 "提交版本必须大于记录当前版本才能执行更新余额。

引入版本号中有版本号的修改是在用户态中完成这个操作的,相对于“单纯互斥的锁”来说,会更轻量一些,因为它会涉及到用户态和内核态的切换。

对于这个引入版本号的机制来说,如果写入失败,就需要重试(如果要是老重试,那么效率就不高了)。但这是乐观锁,在锁冲突的概率比较低的情景下使用,基本很少涉及到重试。同时也能避免用户态和内核态的切换。

  1. 线程 A 此时准备将其读出( version=1, balance=100 ),线程 B 也读入此信息( version=1, balance=100 ).
    在这里插入图片描述
  2. 线程 A 操作的过程中并先从其帐户余额中扣除 50( 100-50 ),线程 B 从其帐户余额中扣除 20 ( 100-20 );
    在这里插入图片描述
  3. 线程 A 完成修改工作,将数据版本号加1( version=2 ),连同帐户扣除后余额( balance=50 ),写回到内存中;
    在这里插入图片描述
  4. 线程 B 完成了操作,也将版本号加1( version=2 )试图向内存中提交数据( balance=80 ),但此时比对版本发现,操作员 B 提交的数据版本号为 2 ,数据库记录的当前版本也为 2 ,不满足 “提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略。就认为这次操作失败.
    在这里插入图片描述
    乐观锁的使用场景还是比较多的,例如一些用户量不多的、小门小户的网站,就可以优先考虑乐观锁。

2. 读写锁

读和写比较容易存在线程安全的问题。例如:
1.多个线程尝试修改同一个变量是线程不安全的。因此两个写线程之间,就需要互斥的锁。
2.多个线程尝试读取同一个变量是线程安全的。因此两个写线程之间,就不需要互斥的锁。
3.一个读线程一个写线程之间,其实也存在线程安全问题,也需要互斥。

有些场景中,本来就是 写 比较少,读 比较多情况。因此可以根据不同的情景,给读和写分别加锁。

synchronized没有对读和写进行区分,因此直接使用肯定会互斥的。
Java标准库中提供了专门给 读 和 写 提高效率的锁。

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

如果某个线程读数据,就使用ReentrantReadWriteLock.ReadLock,它是不会互斥的,如果多个线程都是在读数据,本质上和没有加锁一样。
如果某个线程如果是写数据,就使用ReentrantReadWriteLock.WriteLock,它与ReentrantReadWriteLock.ReadLock和它本身都是会互斥的。

假设现在有10个线程,t0和t1是写线程,t2到t9是读线程。
1.假设,如果t2和t3两个读线程同时来访问数据,此时两个读锁之间是不会互斥的,完全并发地去执行。(就好像完全没加锁一样)
2.假设,如果t1和t2一个线程读一个线程写,此时读锁和写锁之间会互斥,因此要么是写完再读,要么是读完再写。
3.假设,如果t0和t1两个写线程同时访问数据,此时写锁和写锁之间会互斥,一定是任意一个线程写完后另一个线程再写。

对于读比较多,写比较少的情景,使用读写锁,能够大大地提高效率。(降低锁冲突的概率)。锁一旦冲突,就会阻塞等待,等待的时间是不确定的并且会影响程序的效率。这种情况还是比较常见的

3. 重量级锁和轻量级锁

注:这些锁策略和策略之间,并不是完全互不相干的,可能会有部分的重叠

重量级锁:加锁解锁的开销很大,往往是通过内核来完成的。
轻量级锁:开锁解锁的开销更小,往往是通过用户态来完成的。
这两个锁的使用跟应用场景没有什么关系,主要看加锁和解锁的开销大不大。

对比于乐观锁和悲观锁。它们两个锁都是按照应用场景来使用的,锁冲突的概率高不高来决定。一定要注意区分
乐观锁:做的工作往往更少,因此开销更小,乐观锁在一定的程度上有很大可能性是轻量级锁。
悲观锁:做的工作往往更多,因此开销更大,悲观锁在一定的程度上有很大可能性是重量级锁。

加锁的互斥到底是从哪里来的呢?
归根结底,是CPU的能力。CPU提供了一些特殊的指令(原子操作的指令),通过这些指令来完成互斥。操作系统内核对这些指令进行了封装,并实现了阻塞等待,提供了mutex(互斥量),像Linux就会提供一个mutex接口让用户代码进行加锁解锁

而JVM相对于操作系统提供的mutex再封装一层,实现了synchronized这样的锁
如果当前的锁通过内核mutex来完成的,此时这样的锁往往开销比较大。
如果当前的锁是在用户态,通过一些其它的手段来完成的,这样的锁开销就更小。

synchronized可能是一个重量级锁也可能是个轻量级锁,根据具体的情景来决定。

4. 自旋锁和等待挂起锁

自旋锁:

按之前的方式,线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度.
但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题。因此就节省了操作系统调度线程的开销,要比挂起等待锁更能及时地获取到锁。只不过相比之下更浪费CPU资源

自旋锁伪代码:

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

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

挂起等待锁:
如果线程获取不到锁,就会阻塞等待。啥时候结束阻塞是不确定的,这个要取决于操作系统的调度,当线程挂起的时候,是不占用CPU的。

使用自旋锁和挂起等待锁的场景:
大的原则:
1.如果锁冲突的概率比较低,使用自旋锁比起挂起等待锁,更合适。
2.如果线程持有锁的时间比较短,使用自旋锁也比挂起等待锁更合适。
3.如果对CPU比较敏感的,不希望吃太多的CPU资源,那么就使用用挂起等待锁。

自旋锁和挂起等待锁的使用都已经在synchronized中内置了,它会根据具体的场景去自适应。

5. 公平锁和非公平锁

对于公平锁来说,遵循的是先来后到原则。对于非公平锁来说,遵循的是抢到锁的概率是相同的原则,完全是取决于系统的调度

要想实现公平锁,就需要有额外的数据结构(比如队列,通过这个队列来记录这个先来后到的顺序)。

具体啥时候用公平锁,啥时候用非公平锁,得看具体的需求。
大部分情况下,非公平锁就够了。但有些场景下,我们期望对于线程的调度的时间成本是可控的,那么此时就可以用公平锁。

假设发射卫星要用到锁,这个要非常经确,并且确保每个部分都要执行具体的工作。假设此时需要10个线程来并发执行10个任务。此时如果使用非公平锁,可能会出现极端的情况,其中9个线程可能会一直霸占着锁,第10个线程可能会拿不到锁(虽然概率极小,但也会有可能发生),如果使用公平锁,能够保证着这10个任务能够均衡地去执行。

正因为有这样的需求,因此才有一种叫“实时操作系统”(风河公司研发的vxworks),就类似于公平锁的逻辑来执行的。windows、mac、andriod、Linux都不是实时操作系统(线程调度花的时间是不可控的)

synchronized是非公平锁

6. 可重入锁和不可重入锁

针对同一把锁加锁了两次,如果是可重入锁,则不会造成死锁;如果是不可重入锁,就会造成死锁。

例如:因为synchronized修饰的是普通方法,因此这两个方法都是在针对同一个对象this来加锁。

synchronized void func1() {
    func2();
}
synchronized void func2() {
   
}

解释:func1加锁成功,此时进入方法体中调用func2,按照原有的理解,此时锁已经被占用了,func2是获取不到锁的,就会一直阻塞等待,此时会造成一个非常尴尬的死锁的局面。

但是如果该锁是一个可重入锁,那么在func1加锁成功时,会有一个计时器count记录加锁的次数,此时会+1,进入到方法内部调用func2时,因为加的是同一把锁,那么count会再+1,此时func2调用完毕,count会-1,回到func1后也退出方法体,那么count会-1,此时count就已经是0,此时才真正地释放锁,才能让其它线程去获取这把锁。

synchronized就是一个可重入锁。

二、CAS

1.概念

CAS: 全称Compare and swap,字面意思:”比较并交换“。compare是拿两个内存进行比较,或者是拿寄存器的值与内存的值进行比较;而swap是交换这两个内存的值。一个 CAS 涉及到以下操作:
我们假设内存中的原数据V,旧的预期值A,需要修改的新值B:
1.比较 A 与 V 是否相等。(比较)
2.如果比较相等,将 B 写入 V。(交换)
3.返回操作是否成功。

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

boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
   &address = swapValue;
        return true;
   }
    return false; 
 }

当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式)

2. CAS的应用

2.1实现原子类

例如之前的i++案例,多线程进行i++如果不加锁是线程不安全的,但是加锁操作是比较低效的。因此使用CAS就能够高效地完成自增,并且线程是安全的。

java的CAS利用的是unsafe这个类提供的CAS操作。
例如:

public class ThreadDemo8 {
    public static void main(String[] args) {
        AtomicInteger num = new AtomicInteger(10);
        //相当于num++
        //这样的方法能够保证原子性,内部就是通过CAS来实现的
        num.getAndIncrement();
        //相当于++num
        num.incrementAndGet();
        System.out.println(num);
    }
}

点入getAndIncrement方法内部:
在这里插入图片描述
可以看到是unsafe类调用了一个方法,点进该方法里面:
在这里插入图片描述
红色框起来的就是标准库中提供的CAS方法。

伪代码实现:

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 赋值
    在这里插入图片描述

  3. 线程2 再执行 CAS 操作, 第一次 CAS 的时候发现 oldValue 和 value 不相等, 不能进行赋值. 因此需要进入循环.
    在循环里重新读取 value 的值赋给 oldValue
    在这里插入图片描述

  4. 线程2 接下来第二次执行 CAS, 此时 oldValue 和 value 相同, 于是直接执行赋值操作
    在这里插入图片描述
    通过形如上述代码就可以实现一个原子类. 不需要使用重量级锁, 就可以高效的完成多线程的自增操作。

在这里插入图片描述
CAS其实就是在感知 这两个 操作之间是否夹杂了其它线程的操作;是否有其它线程在这个过程中篡改了数据。如果没有线程修改,此时就把数据给改了;如果数据被其它线程已经修改过了,那就重新读取旧值,交给下次循环来判定

2.2 实现自旋锁

基于 CAS 实现更灵活的锁, 获取到更多的控制权.

自旋锁伪代码:

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

CAS是基于CPU的指令,来一次性地完成 比较 和 交换 这样的过程。尤其不加锁能保证线程安全。

队列中有种特殊的队列:无锁队列。无锁队列如何保证线程安全呢?就是利用的CAS。
这里的无锁准确来说不是真的没有锁,而是没有操作系统提供的那个重量级的锁mutex。而是通过CAS,直接在用户态实现了一个轻量级的自旋锁,通过这个自旋锁来保证线程的安全。

2.3 CAS中的ABA问题

ABA问题指的是:
假设存在两个线程 t1 和 t2. 有一个共享变量 num, 初始值为 A。
接下来, 线程 t1 想使用 CAS 把 num 值改成 Z, 那么就需要:
1.先读取 num 的值, 记录到 oldNum 变量中.
2.使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.

但是, 在 t1 执行这两个操作之间, t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A。
线程 t1 的 CAS 是期望 num 不变就修改. 但是 num 的值已经被 t2 给改了. 只不过又改成 A 了. 这个时候 t1 究竟是否要更新 num 的值为 Z 呢?

我们可以举个例子:
假设A去银行取钱,存款为100。例如A要取50块钱。

没有ABA的情况下:
a) A去取钱,存款为100,线程1获取到当前存款为100,期望更新为50;线程2 获取到当前存款值为 100, 期望更新为 50。
b) 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中
c) 轮到线程2 执行了, 发现当前存款为 50, 和之前读到的 100 不相同, 执行失败

有ABA的情况下:
a) A去取钱,存款为100,线程1获取到当前存款为100,期望更新为50;线程2 获取到当前存款值为 100, 期望更新为 50。
2) 线程1 执行扣款成功, 存款被改成 50. 线程2 阻塞等待中
3) 在线程2 执行之前, A的朋友正好给A转账 50, 账户余额变成 100
4) 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的 100 相同, 再次执行扣款操作

注意:存钱不会涉及到CAS操作,取钱才涉及到CAS操作。

此时就会发现A要取的是50,为什么最后取的却是100??是ABA存在的原因。
其实出现ABA的概率是比较低的,但是导致的结果却非常严重,并且如果人的基数很大,那么即使是小概率事件,会发现ABA问题的人也会很多。

解决方法:引入版本号,在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期。
1.CAS 操作在读取旧值的同时, 也要读取版本号
2.真正修改的时候:
a) 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
b) 如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).

再有上面的那个场景:
此时两个线程都尝试进行-50的操作,此处给100这个数据一个版本号1,后续每次对其进行操作,都要对其版本号+1,当CAS进行对比的时候,对比的不是数值本身,而是对比版本号。版本号相同才修改,版本号不同就不修改。

a) 线程1获取到当前版本号为1,线程2也获取到当前版本号为1.
b) 线程1执行-50操作,对比当前的版本号,因为都是1,一致就扣钱。线程1执行完毕之后,账户余额就变为50.同时把当前版本号改为了2.
c) 线程3执行+50操作,余额变为了100的同时版本号变为了3.
d) 线程2执行-50操作,线程2的版本号为1与当前版本号不同,则操作不执行。

三、synchronized的原理

1.synchronized的基本特点

学习了上面的锁策略,就可以对synchronized做出一个直观的认识:
1.开始使用的时候是乐观锁,如果发现锁的冲突概率比较高,就会自动转成悲观锁。
2.synchronized不是读写锁
3.synchronized开始的时候是轻量锁,如果锁被持有的时间较长/锁的冲突概率比较高,就会升级为重量级锁。
4.synchronized是一个非公平锁
5.synchronized是一个可重入锁
6.synchronized是一个轻量级锁的时候,大概率是一个自旋锁;为重量级锁的时候大概率是一个挂起等待锁

2.synchronized的加锁工作过程

JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。下面的这个过程可以称为“锁升级”,也可以是“锁膨胀”。
在这里插入图片描述
a) 偏向锁
偏向锁其实也是一个乐观锁。偏向锁只是会在对象头中设置一个“偏向锁标记”,这个只做的是标记,就好比真正的锁,不会涉及到开销就会高效很多。
它就是在赌这个锁会不会竞争:
赌赢了就真的是不加锁,直到这个锁的释放。这整个过程中都没有涉及到加锁解锁的过程。
赌输了就比如线程1尝试获取这把锁,锁进入偏向的状态,此时如果有一个线程2也尝试竞争这把锁,那么线程1此时就会抢先先把这个锁抢到,然后线程2就阻塞等待。

偏向锁本质上相当于一种“延时加锁”,完全没竞争的时候,就是一个偏向锁。如果出现了竞争,但是这个时候竞争还比较小,就会变为一个“轻量级“状态。

b) 此处的轻量级锁,就是基于CAS实现的自旋锁。是属于完全在用户态完成的操作。因此这里面不涉及到用户态内核态的切换,也不涉及到线程的阻塞等待和调度。只是多费了一些CPU而已,但是能够保证更高效的获取到锁。(线程1释放了锁,线程2能够立刻获取到)。

c) 但如果当前的场景,是锁冲突概率比较大,锁的竞争比较激烈的话,此时锁还会进一步的膨胀成重量级锁。
如果锁冲突概率比较大,还使用轻量级锁的话,会浪费大量的CPU资源(CPU会一直空转)。
使用更重量的挂起等待锁,就可以解决这个问题。它在阻塞等待的时候是不占用CPU资源的,但是会根据操作系统内核的调度来决定什么时候加锁,这个时间是不准确的。它的代价就是引入了线程阻塞和调度开销。

注:Java8中没有锁降级这一说法,比如说某个场景,突然遇到了一个请求峰值(平时的请求很低,锁冲突概率也很低,以轻量级锁状态工作即可),锁冲突的概率高了,就膨胀成重量级锁。

过了一阵峰值下降了,请求又少了,这个时候锁冲突的概率就会下降,在Java8中synchronized不会自动进行锁降级,锁降级这个说法在JVM和Java中是没有规定的。具体是否能降级,这样的行为取决于JVM具体的实现

3.synchronized其它的优化操作

3.1 锁消除

编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除。
例如:StringBuffer是加了锁的,因此是线程安全的。但是我们之前都是建议在单线程中不要使用StringBuffer,因为涉及到加锁和解锁。但是学到这里我们知道Java中会对单线程的加锁作消除。

有些应用程序的代码中, 用到了 synchronized, 但其实没有在多线程环境下. (例如 StringBuffer)

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

此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销,因此JVM和编译器就帮我们自动去除掉锁了。

虽然编译器很智能,但是对锁的消除的判断它不一定是100%拿捏的准的。因此有些地方确实不该加锁,那么编译器不能准确地就消除掉。一个好的代码需要程序员自己去实现。

3.2 锁粗化

一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化.

首先要了解锁粒度:锁粒度指的是锁中包含代码的多少,越多则锁粒度越大,反之越少。

在写代码的时候,一般都认为是锁粒度越小越好,因为代码少则执行的效率肯定会提高但是有些情况下锁粒度却是越大越好

例如:在这种情况下会出现频繁加锁和解锁的情况,是非常影响效率的,而且也没有必要。

func1() {
   synchronized(this) {
       任务1
   }
   synchronized(this) {
       任务2
   }
   synchronized(this) {
       任务3
   }
}

但是只用一把锁去执行该线程可以大大地减少加锁解锁的开销,提高效率。

func1() {
   synchronized(this) {
       任务1
       任务2
       任务3
   }
}

JVM和编译器也会进行一些智能的判定,会把这个情况下的多组synchronized直接合并成一组

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

JUC就是Java标准库中包含了很多和并发相关的操作(和多线程相关的操作)

1. ReentrantLock

ReentrantLock是可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全的。之前讨论的可重入锁,翻译成英文就是ReentrantLock,它是和synchronized是并列的关系。

平时说的”可重入锁“,大部分情况下表示的是锁的特性,但是少数情况可能就是特指标准库中的这个类了。

ReentrantLock中的开锁和解锁可以分开来处理:

public class ThreadDemo12 {
    static class Counter {
        int count = 0;
        public ReentrantLock locker = new ReentrantLock();

        public void increase() {
           locker.lock();
           count++;
           locker.unlock();
        }
    }
    public static void main(String[] args) {
        Counter counter = new Counter();

        Thread t1 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
        Thread t2 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(counter.count);
    }
}
//打印结果:100000

虽然synchronized已经非常先进了,但是还是有一些功能是不具备的。ReentrantLock相当于对synchronized进行了补充。

对ReentrantLock的总结:
1.ReentrantLock把加锁解锁两个操作拆成两个方法,虽然回有遗忘的风险,但是可以在代码中更加灵活。
2.Reentrantlock除了lock和unlock方法外,还提供了一个tryLock方法。对于lock方法来说,如果尝试加锁失败,就会阻塞等待。对于tryLock方法来说,如果尝试加锁失败,就会直接返回错误,不会阻塞等待。
3.synchronized是一个非公平锁,而ReentrantLock支持两种模式:既可以支持公平锁,也可以支持非公平锁。
下面的这个就是公平锁的ReentrantLock的创建方式:
在这里插入图片描述
4.ReentrantLock提供了比synchronized更强大的 等待唤醒 机制。synchronized是搭配wait和notify,而ReentrantLock是搭配了另外一个类 Condition 类来完成等待唤醒,它能够显式地指定唤醒哪个等待的线程。

大部分情况下使用synchronized就够了,除非开发中有使用ReentrantLock特性的必要。

2. 原子类

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

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

以 AtomicInteger 举例,常见方法有:

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

将上面利用ReentrantLock的加锁解锁改为使用原子类来计算累加和:

public class ThreadDemo13 {
    static class Counter {
        AtomicInteger count = new AtomicInteger(0);

        public void increase() {
            count.incrementAndGet();//++i
        }
    }
    public static void main(String[] args) {
        ThreadDemo12.Counter counter = new ThreadDemo12.Counter();

        Thread t1 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
        Thread t2 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 50000; i++) {
                    counter.increase();
                }
            }
        };
        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(counter.count);
    }
}

原子类在工作中会经常用到,会使用原子类来做一些监控。
例如:需要了解服务器的运行状态:
1.单位时间内收到了多少个请求。
2.服务器内部某个类被创建出多少个实例。
3.某个方法被调用了多少次。
4.某个代码片段执行了多长时间。

把这些统计的数字汇总到一个地方,(统一有一个监控服务器来收集这些数据)。接下来程序员就可以在监控服务器上看到服务器的运行状态。

3. 信号量Semaphore

信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器(int整数)。
功能:描述可用资源的个数。也可以使用信号量来控制线程安全。
例如:
在这里插入图片描述
创建信号量的时候,可以给一个初始值。如果把初始值设为1了,那么信号量就只有 0 1 两种取值,称为“二元信号量”,二元信号量就和锁的功能是类似的

进程间的通信方式:网络(Socket),文件、管道、消息队列(此处的消息队列是操作系统内核提供的机制)、信号量(此处的信号量是操作系统内核提供的机制)、信号…
计算机中的很多概念可能都是一个概念,但在不同的场景中,表示的含义不一样。

使用Semaphore:
申请资源方法是acquire,释放资源方法是release

public class ThreadDemo33 {
    public static void main(String[] args) {
        Semaphore semaphore = new Semaphore(4);
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                // 先尝试申请资源
                try {
                    System.out.println("准备申请资源");
                    semaphore.acquire();
                    System.out.println("申请资源成功");
                    // 申请到了之后, sleep 1000 ms
                    Thread.sleep(1000);
                    semaphore.release();
                    // 再释放资源
                    System.out.println("释放资源完毕");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        };

        // 创建 20 个线程.
        // 让这 20 个线程来分别去尝试申请资源
        for (int i = 0; i < 20; i++) {
            Thread t = new Thread(runnable);
            t.start();
        }
    }
}

打印结果:
在这里插入图片描述

4. CountDownLatch

CountDownLatch的作用是同时等待 N 个任务执行结束。
可以假设有8个选手,参加100米,8个选手同时起跑,这8个选手达到终点之后这场比赛就完了。每次有选手撞线,CountDownLatch就-1,一直减到0,就说明比赛结束,代码不再阻塞,继续往下跑。

假设某个场景中,需要通过多线程来计算一组数据。t0创建出4个线程t1~t4,t0来汇总这4个线程的结果。t0就可以使用CountDownLatch。await方法就会阻塞等待,等待这些线程执行完毕。t1-t4每个线程在执行完任务的时候,都使用CountDownLatch方法,来“撞线”,当四个线程都撞线了之后,await就返回了,就自动往后执行了

CountDownLatch的使用:

public class ThreadDemo34 {
    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(8);
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("起跑!");
                // random 方法得到的是一个 [0, 1) 之间的浮点数.
                // sleep 的单位是 ms, 此处 * 10000 意思是 sleep [0, 10) 区间范围内的秒数
                try {
                    Thread.sleep((long) (Math.random() * 10000));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                latch.countDown();
                System.out.println("撞线完成!");
            }
        };
        for (int i = 0; i < 8; i++) {
            Thread t = new Thread(runnable);
            t.start();
        }
        latch.await();
        System.out.println("比赛结束");
    }
}

五、线程安全的集合类

原来的集合类, 大部分都不是线程安全的,Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的。
HashTable已经被官方标注成“即将废弃”,这几个类诞生的时间都是比较早的,当时控制线程安全的方式有限,当时的synchronized也不像现在的优化程序这么高。虽然这三个类能凑合用,但是比较低效。

一个特殊的类:String。String是线程安全的,但是String的内部并没有进行加锁,那为什么String是线程安全的呢?
原因:String是一个不可变的对象,指的就是没有public方法来修改String中的内容

1. 多线程环境使用 ArrayList

  1. 自己使用同步机制 (synchronized 或者 ReentrantLock)
  2. Collections.synchronizedList(new ArrayList);
    synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
    synchronizedList 的关键操作上都带有 synchronized,能够保证关键操作都是线程安全的
  3. 使用 CopyOnWriteArrayList
    CopyOnWriteArrayList是写时拷贝。如果只有一个线程在写,那么就只有一份实例;如果又来了一个线程读数据,此时就会在原来的基础上搞一个副本

1.当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素。
2.添加完元素之后,再将原容器的引用指向新的容器
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

优点:在读多写少的场景下, 性能很高, 不需要加锁竞争。
缺点:1.占用内存较多. 2.新写的数据不能被第一时间读取到

2. 多线程环境使用队列

  1. ArrayBlockingQueue
    基于数组实现的阻塞队列
  2. LinkedBlockingQueue
    基于链表实现的阻塞队列
  3. PriorityBlockingQueue
    基于堆实现的带优先级的阻塞队列
  4. TransferQueue
    最多只包含一个元素的阻塞队列

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

HashMap 本身不是线程安全的,
在多线程环境下使用哈希表可以使用:
Hashtable
ConcurrentHashMap

在实际开发中,ConcurrentHashMap运用的场景很多,它的优点也很明显。
ConcurrentHashMap其实也是用synchronized来加锁,但是加锁的方式和HashTable的加锁方式区别很大。

HashTable是直接针对this对象来加锁,一个HashTable实例就只有一把锁,此时如果有10个线程并发修改HashTable里面的内容,那么这10个线程就是共同竞争同一把锁了。(锁冲突的概率比较高)

ConcurrentHashMap锁对象是针对每个哈希桶来进行加锁:
在这里插入图片描述
此时如果有10个线程并发修改哈希表,此时如果当前线程计算出的数组的位置(hashcode%数组长度)是不相同的,那么此时就没有锁竞争。即使有两个线程修改的元素正好在同一个数组位置上,此时才会发生锁竞争。

ConcurrentHashMap对比HashTable的好处
1.ConcurrentHashMap针对修改操作的加锁,使用的是锁粒度更小的锁,针对的是每个哈希桶来分别设定锁,能够大大地降低锁冲突的概率。
2.ConcurrentHashMap针对读操作没有加锁,而是直接使用volatile,设计者评估说读操作的影响不大,读到一个旧的值和读到一个新的值,对于实际开发中是没有明显的影响。
3.ConcurrentHashMap更充分地利用了CAS的特性,比如获取/修改 size属性(元素个数)
4.ConcurrentHashMap使用更优化的扩容方式:
HashTable的扩容:如果某次put操作,导致当前的元素个数太多了,就会触发扩容,这个扩容就需要创建更大的内存,并且把数据都复制过去。这样会导致这一次的插入操作会非常低效。

而ConcurrentHashMap的扩容思路:“化整为零”:
如果某次的插入操作触发了扩容,它不是一次性就全部扩容完,而是只搬运一部分的元素,下次再对ConcurrentHashMap操作的时候,就再搬运一部分,这样能够保证每次的操作都不至于太慢。

在这个搬运的过程中,相当于内部维护了两套内存,一套是旧的数据,一套是新的数据。有新的和旧的再插入时,就会往新数据中插入。查找操作就会同时查旧的数据和新的数据。当完全搬运完成,就会删除掉旧的内存。

ConcurrentHashMap的相关面试题:

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

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

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

六、死锁

1. 概念

多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线
程被无限期地阻塞,因此程序不可能正常终止。那么就称该锁是一个死锁,是根据不同的场景来决定的。

2. 产生死锁的场景

1.如果一个线程针对一把锁,连续尝试加锁两次,并且该锁不是可重入锁的时候:
如:

synchronized void func() {
   synchronized (this) {
     ....
   }
}     

假设synchronized不是可重入锁(但实际上是一个可重入锁,实际上这个代码不会死锁),该代码场景就会造成死锁的情况。但是synchronized内部记录了当前这个锁是由哪个线程持有的。因此当再次尝试加锁的时候,就会进行判定,看看当前加锁尝试加锁的线程是否是已经持有锁的线程。如果是,就不会堵塞,而是把引用计数给自增。

2.两个线程,有两把锁。
代码:

void func1() {
    synchronized (locker1) {
        synchronized (locker2) {
        }
    }
}        

void func2() {
    synchronized (locker2) {
        synchronized (locker1) {
        }
    }
}

这两个线程之间并发执行就可能会造成死锁,如果func1和func2同时分别获取到了locker1和locker2,此时这个代码就会死锁。线程1需要等待线程2解锁locker2,而线程2又在等待线程1解锁locker1.

3.多个线程多把锁
哲学家问题:
在这里插入图片描述
要解决这一问题,就需要约定按照编号 “升序” 来加锁。

正中间的人先拿1筷子,接着右上角的拿筷子2,接下来的拿筷子3,再有的拿筷子4,最后那个人在筷子1和筷子5中选筷子1的,那么此时拿筷子4的人就可以拿起筷子5。以此类推就可以避免死锁。

死锁的四个必要条件(理解不要背):
1.互斥使用。如果一个锁被一个线程占用的时候,别的线程就会阻塞等待。
2.不可抢占/不可剥夺,线程1如果获取到一把锁,此时的线程2不能强行把锁抢过来
3.请求和保持。当资源的请求者在请求其它资源的时候,同时要保持之前的资源(线程1获取到锁1之后,再尝试获取锁2,此时仍然保持对锁1的持有)
4.循环等待。线程1,先尝试获取锁1和锁2;线程2,先尝试获取锁2 和锁1 的情况下就是循环等待造成死锁。

3. 避免死锁的解决方法

如果站在开发的角度,一些简单明了的方案:
1.尽量避免复杂的设计,避免在某个锁的代码中,再尝试获取其它锁。(但不是所有的代码都可以保证不嵌套锁)
2.如果实在需要进行锁的嵌套使用,一方面要保证锁的时间足够短,代码也要足够简单。另一方面要保证按照统一的顺序来进行加锁。
例如:有locker1和locker2两把锁,让所有要使用两把锁的线程都先获取locker1再获取locker2.这样的固定顺序,其实就是破坏了死锁的“循环等待”条件。

在实际开发中,会经常涉及到“分布式锁”这个概念,不再依赖于synchronized。“分布式锁”存在的意义,就是在一个分布式的系统中,能够起到互斥的效果。

如果是单机数据库,其实用事务就可以做到线程安全;如果是集群的数据库,就无法再使用事务,这时可能需要分布式的锁。
例如此时有两个服务器,其中一个服务器提供两个网络接口lock和unlock,另一个服务器就用来维护数据,有个引用计数器默认是0,调用lock为1,再调用unlock为0.

如果应用服务器要想写数据库集群,就先调用这个另外的服务器的lock接口。写完之后再调用unlock。

因此,分布式的系统中可能会出现死锁的情况。(此时如果两个服务器同时在写数据,就可能会出现错误)
分布式系统避免死锁的方案:
1.约定锁的大小顺序来加锁解锁。
2.为了防止lock过程中,主机宕机导致无法解锁出现的死锁,此时就可以给这个锁设置一个过期时间,即使有服务器宕机,那个锁到了过期时间就会自动解锁。

  • 26
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 23
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 23
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

zjruiiiiii

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

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

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

打赏作者

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

抵扣说明:

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

余额充值