【JavaEE -- 多线程进阶 - 面试重点】

多线程进阶

  • [1. 常见锁策略](#1. 常见锁策略)
    • [1.1 乐观锁和悲观锁](#1.1 乐观锁和悲观锁)
    • [1.2 轻量级锁和重量级锁](#1.2 轻量级锁和重量级锁)
    • [1.3 自旋锁和挂起等待锁](#1.3 自旋锁和挂起等待锁)
    • synchronized具有自适应能力
    • [1.4 普通互斥锁和读写锁](#1.4 普通互斥锁和读写锁)
    • [1.5 公平锁和非公平锁](#1.5 公平锁和非公平锁)
    • [1.6 可重入锁和不可重入锁](#1.6 可重入锁和不可重入锁)
  • [2. Synchronized原理(特点、加锁过程、自适应)](#2. Synchronized原理(特点、加锁过程、自适应))
    • [2.1 Synchronized基本特点](#2.1 Synchronized基本特点)
    • [2.2 Synchronized加锁过程](#2.2 Synchronized加锁过程)
    • [2.3 锁消除](#2.3 锁消除)
    • [2.4 锁粗化](#2.4 锁粗化)
  • [3. CAS(compare and swap)](#3. CAS(compare and swap))
    • [3.1 CAS 的ABA问题怎么解决](#3.1 CAS 的ABA问题怎么解决)
  • [4. Callable接口](#4. Callable接口)
  • [5. JUC(java.util.concurrent)的常见类](#5. JUC(java.util.concurrent)的常见类)
    • [5.1 ReentrantLock](#5.1 ReentrantLock)
  • [6. 信号量Semaphore](#6. 信号量Semaphore)
    • [6.1 使用Semaphore可以保证线程安全,就相当于synchronized加锁](#6.1 使用Semaphore可以保证线程安全,就相当于synchronized加锁)
  • [7. CountDownLatch](#7. CountDownLatch)
  • [8. ConcurrentHashMap](#8. ConcurrentHashMap)
    • [8.1 ConcurrentHashMap(Java1.8)的改进](#8.1 ConcurrentHashMap(Java1.8)的改进)

1. 常见锁策略

加锁过程中,处理冲突的过程中,涉及到一些不同的处理方式。

1.1 乐观锁和悲观锁

  • 乐观锁:在加锁之前,预估当前出现锁冲突的概率不大,因此在进行加锁的时候就不会做太多的工作,加锁的过程中做的事情比较少,加锁的速度比较快,但是更容易引入一些其他的问题,可能会消耗更多的cpu资源
  • 悲观锁:在加锁之前,预估当前锁冲突出现的概率比较大,因此在加锁的时候做更多的工作,做的事情更多,加锁的速度可能更慢,但是整个过程中不容易出现其他问题。
  • 乐观锁的实现:引入一个版本号,借助版本号识别出当前的数据访问是否冲突
  • 悲观锁的实现:先加锁(比如借助操作系统提供的mutex),获取到锁再操作数据,获取不到就等待

1.2 轻量级锁和重量级锁

按加锁开销的大小分:

  • 轻量级锁:加锁的开销小,加锁的速度更快,轻量级锁一般就乐观锁。
  • 重量级锁:加锁的开销大,加锁的速度更慢,重量级锁一般是悲观锁。

轻量和重量是加锁之后对结果的评价,乐观和悲观是加锁之前,对未发生的事情进行的预估,整体来说,这两种角度描述的是同一个事情

1.3 自旋锁和挂起等待锁

  • 自旋锁:是轻量级锁的一种典型实现,进行加锁的时候,搭配一个while循环,如果加锁成功,自然循环结束。如果加锁不成功,不是阻塞放弃cpu,而是进行下一次循环,再次尝试获取到锁 。这个反复快速执行 的过程就称为自旋。一旦其他线程释放了锁,就能第一时间拿到锁。同时这样的自旋锁也就是乐观锁自旋的前提:就是预期冲突的概率不大,其他线程释放了锁,就能第一时间拿到锁。当加锁的线程很多,自旋的意义就不大了,会浪费CPU资源。
  • 挂起等待锁:重量级锁的一种典型实现,进行等待挂起等待的时候,就需要内核调度器介入,这一块要完成的操作很多,真正获取到锁花的时间就更多。同时也是一种悲观锁,这个锁可以适用于锁冲突激烈的情况

synchronized具有自适应能力

  • 如果当前锁冲突的激烈程度不大 ,就处于乐观锁/轻量级锁/自旋锁
  • 如果当前锁冲突的激烈程度很大 ,就处于悲观锁/重量级锁/挂起等待锁

1.4 普通互斥锁和读写锁

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

  1. 两个线程都只是读一个数据,此时并没有线程安全问题,之间并发的读取即可
  2. 两个线程都要写一个数据,有线程安全问题
  3. 一个线程读另外一个线程写,有线程安全问题
  • 普通互斥锁:多线程之间,数据的读取时之间不会产生线程安全问题,但是数据的写入方互相之间以及和读者之间都需要进行互斥。如果两种场景下都用同一个锁,就会产生极大的性能损耗,所以引出读写锁。
  • 读写锁:在执行加锁操作时需要额外表明读写意图,复数读者之间并不互斥,而写者则要求与任何人互斥 。读写锁分为两种情况,加 读锁和加 写锁
  1. 读锁和读锁之间,不会出现锁冲突(不会阻塞,即不互斥)
  2. 写锁和写锁之间,会出现锁冲突(会阻塞,即互斥)
  3. 读锁和写锁之间,会出现锁冲突(会阻塞,即互斥)

一个线程加 读锁的时候,另一个线程只能读,不能写。一个线程加 写锁的时候,另一个线程,不能读也不能写

1.5 公平锁和非公平锁

假如三个线程A,B,C,A先尝试获取锁,获取成功,然后B再尝试获取锁,获取失败,阻塞等待;然后C也尝试获取锁,C也获取失败,阻塞等待。

  • 公平锁:遵守 "先来后到",当A释放锁,B先于C拿到锁。
  • 非公平锁:B和C都有可能获取到锁
    Java中synchronized是非公平锁

1.6 可重入锁和不可重入锁

  • 可重入锁:一个线程针对一把锁,连续两次加锁,不会死锁。synchronized是可重入锁,实现方式:在加锁中记录锁持有的线程身份,以及一个计数器(记录加锁次数),如果发现加锁的线程就是持有锁的线程,则直接计数自增
  • 不可重入锁:一个线程针对一把锁,连续两次加锁,会死锁。系统自带的锁是不可重入的锁

2. Synchronized原理(特点、加锁过程、自适应)

2.1 Synchronized基本特点

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

2.2 Synchronized加锁过程

当线程执行到Synchronized的时候,如果这个对象当前处于为加锁的状态,就会经历以下过程:

  1. 偏向锁阶段 :假设没有线程竞争。 核心思想就是懒汉模式,能不加锁就不加锁,能晚加锁就晚加锁。所谓的偏向锁,并非真的加锁了,而只是做了一个非常轻量的标记。非必要不加锁,在遇到竞争的情况下,偏向锁没有提高效率,但如果实在没有竞争的情况下,偏向锁就大幅度提高了效率
  2. 轻量级锁阶段 :假设有竞争,但是不多。此处就是通过自旋锁的方式来实现的;优势:另外的线程把锁释放了,就会第一时间拿到锁。劣势:比较消耗cpu,即对于自旋锁来说,如果同一个锁竞争者很多,大量线程都在自旋,整体cpu的消耗就很大 。此同时,synchronized内部会统计当前这个锁对象上,有多少个线程在参与竞争,当参与竞争的线程多了就会进一步升级到重量级锁
  3. 重量级锁阶段 :锁竞争进一步激烈,此处的重量级锁就是指用到内核提供的mutex,此时拿不到锁的线程就不会继续自旋了,而是进入阻塞等待,让出cpu,当当前线程释放锁的时候,就由系统随机唤醒一个来获取锁

2.3 锁消除

锁消除也是synchronized中内置的优化策略。编译器优化的一种方式,编译器编译代码的时候,如果发现这个代码,不需要加锁,就会自动把锁给干掉。针对一眼看上去就不涉及安全问题的代码,能够把锁消除掉,对于其他的很多模棱两可的,都不会消除。

2.4 锁粗化

锁粗化,会把多个细粒度的锁,合并成一个粗粒度的锁 。粒度指的是synchronized {}里大括号里面包含代码越少,就认为锁的粒度越细,包含的代码越多,就认为锁的粒度越粗。

通常情况下,是更偏于让锁的粒度细一些,更有利于多个线程并发执行的,但有时候也希望粒度粗点好 .

如A给B交代任务,打电话,交代任务1,挂电话。打电话,交代任务2,挂电话。打电话,交代任务3,挂电话。粗化成,打电话,交代任务1,2,3.挂电话。把这三个合并一个粗粒度的锁,粗化提高了效率

小结:synchronized的优化操作

  • 锁升级:偏向锁 -> 轻量级锁 -> 重量级锁
  • 锁消除:自动干掉不必要的锁
  • 锁粗化:把多个细粒度的锁合并成一个粗粒度的锁,减少锁竞争。

3. CAS(compare and swap)

CAS是一个特殊的cpu的指令,完成的工作就是 比较 和 交换。是单个cpu指令,本身是原子的,同时完成 读取内存,比较是否相等,修改内存

CAS的伪代码:
比较address内存地址中的值,是否和expected寄存器中的值相同,如果相同,就把swap寄存器的值和address内存的值,进行交换,返回true。如果不相同,直接返回false。

  • CAS本身是cpu指令,操作系统对指令进行了封装,jvm又对操作系统提供的api又封装了一层,有的cpu可能会不支持cas,而Java的标准库,对于CAS又进行了进一步的封装,提供了一些工具类,里面最主要的一个工具,原子类
  • 之前的线程安全都是靠加锁,加锁-》阻塞-》性能降低。基于CAS指令,不涉及加锁,不会阻塞,合理使用也保证线程安全,即无锁编程
java 复制代码
public class ThreadDemo34  {
    // AtomicInteger: Java标准库中,对cas又进一步的封装,提供了一些工具类,即原子类
    // 使用原生的int 会出现线程安全问题 ,不使用加锁,使用AtomicInteger替换int也能保证线程安全
    // private static int count = 0;
    private 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++) {
                //getAndIncrement 对变量的修改,是一个cas指令,即这个指令天然就是原子的
                //count++;
                count.getAndIncrement();
                // ++count;
                //count.incrementAndGet();
                // count += n;
                //count.getAndAdd(n);
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                //count++;
                count.getAndIncrement();
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(count);
    }
}

3.1 CAS 的ABA问题怎么解决

假如去ATM取钱,里面有1000,要取500,取钱的时候ATM卡了,按了一下没反应(t1线程),又按了一下(t2线程),此时此时产生了两个线程,去尝试扣款操作,此处 假如按CAS的方式扣款,这样是没问题的。当又来个t3线程给账户存了500,此时t1线程就不知道当前的1000是始终没变还是变了又变回来了。

解决方案

  1. 约定数据变化是单向的(只能增加或者只能减少),不能是双向的(即又增加又能减少)
  2. 对于本身就必须双向变化的数据,可以给它引入一个版本号,版本号这个数字是只能增加,不能减少

4. Callable接口

  1. 继承Thread(包含了匿名内部类的方式)
  2. 实现Runnable(包含了匿名内部类的方式)
  3. 基于lambda
  4. 基于Callable
  5. 基于线程池
  • Runnable关注的是这个过程,不关注执行结果,Runnable提供的是run方法,返回值类型是void
java 复制代码
public class ThreadDemo35 {
    private static  int sum = 0;
    public static void main(String[] args) throws InterruptedException {
        // 创建一个新线程,用新的线程实现从1+到1000

        // 不用callable
        Thread t = new Thread(new Runnable() {
            @Override
            public void run() {
                int result = 0;
                for (int i = 0; i <= 1000; i++) {
                    result += i;
                }
                sum = result;
            }
        });
        t.start();
        t.join();
        // 主线程获取到计算结果
        // 此处想要获取到结果,就需要弄一个成员变量保持上述结果
        System.out.println("sum= " + sum);
    }
}
  • Callable要关注执行结果,Callable提供的call方法,返回值就是线程执行任务得到的结果
  • callable 通常搭配FutureTask来使用,FutureTask用来保存Callable的返回结果,因为callable往往是在另一个线程中执行的,什么时候执行完不确定
  • FutureTask类,作为Thread和callable的粘合剂,使用futureTask.get()获取结果,带有阻塞功能,如果线程还没有执行完,get就会阻塞,等待线程执行完
    当编写多线程代码,希望关注线程中代码的返回值的时候:
    相比于runnable来说,不需要引入额外的成员变量,直接借助这个的返回值即可。
java 复制代码
import java.util.concurrent.*;

// Callable 接口
public class ThreadDemo36 {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 期望线程的入口方法里,返回值是啥类型,此处的泛型就是什么类型 这里希望返回值是Integer
        Callable<Integer> callable = new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                int result = 0;
                for (int i = 0; i <= 1000; i++) {
                    result += i;
                }
                return result;
            }
        };
        //Thread没有提供构造函数传入callable
        // 引入一个FutureTask类,作为Thread和callable的粘合剂 未来的任务,相当于一个凭据
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
		
        Thread t = new Thread(futureTask);
        t.start();

        // 接下来这个代码不需要join,使用futureTask获取到结果
        // futureTask.get() 这个操作也具有阻塞功能,如果线程还没有执行完毕,get就会阻塞,等到线程执行完了,
            //return的结果就会被get返回回来
        System.out.println(futureTask.get());
    }
}

5. JUC(java.util.concurrent)的常见类

5.1 ReentrantLock

用法:

  • lock() :加锁,如果获取不到锁就死等
  • unlock(),解锁
  • trylock(超出时间):加锁,如果获取不到锁,等待一定的时间之后就放弃加锁。
    ReentrantLock,可重入锁,和synchronized一样是可重入锁,有了synchronized为什么还需要ReentrantLock
  1. ReentrantLock提供了tryLock操作,lock直接进行加锁,如果加锁不成,就要阻塞;tryLock,尝试进行加锁,如果加锁成功,不阻塞,直接返回false,即tryLock提供了更多的可操作空间
  2. Reentrant Lock提供了公平锁的实现(通过队列记录加锁线程的先后顺序),ReentrantLock构造方法中填写参数,就可以设置公平锁,而synchronized是非公平锁
  3. 搭配的等待通知机制不同的,synchronized,搭配wait / notify,对于Reentrant Lock,搭配Condition类,功能比wait / notify 略强一点

6. 信号量Semaphore

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

java 复制代码
import java.util.concurrent.Semaphore;

// 信号量 Semaphore
public class ThreadDemo37 {
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(1);
        // 申请1个资源
        semaphore.acquire();
        System.out.println("P 操作");
        semaphore.acquire();
        System.out.println("P 操作");
        // 释放一个资源
        semaphore.release();
        System.out.println("V 操作");
    }
}
  • new一个semaphore对象,有1个可用资源
  • semaphore.acquire :申请一个资源,资源个数 -1(P操作)
  • semaphore.release :释放一个资源,资源个数 +1 (V操作)
  • 如果计数器为0,即没有可用资源了,还申请资源,就会阻塞等待,直到有其他线程释放资源。
  • Semaphore的PV操作的加减操作都是原子的,可以在多线程下直接使用。

所谓的锁,本质上也是一种特殊的信号量,锁,可以认为计数值为1的信号量,释放状态,就是1,加锁状态,就是0,对于非0即1的信号量,称为二元信号量,信号量是更广义的锁

6.1 使用Semaphore可以保证线程安全,就相当于synchronized加锁

使用Semaphore,先申请一个资源然后进行下述count++操作,再进行释放操作,这样也可以确保线程安全

java 复制代码
import java.util.concurrent.Semaphore;

// 信号量 Semaphore
// 在操作前先申请一个可用资源 使数字-1 semaphore.acquire();  后semaphore.release(); 数字+1 释放一个可用资源
// 加锁状态, 就是 0 ,释放状态,就是1 对于非0即1的信号量就称为 二元信号量
public class ThreadDemo38 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(1);
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                try {
                    semaphore.acquire();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                count++;
                semaphore.release();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                try {
                    semaphore.acquire();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                count++;
                semaphore.release();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

小结

确保线程安全的操作:

  1. synchronized
  2. ReentrantLock
  3. CAS(原子类)
  4. Semaphore

7. CountDownLatch

CountDownLatch,同时等待N个任务执行结束,比如,多线程执行一个任务,把大的任务拆成几个部分,由每个线程分别执行

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

** join() ,就只能每个线程执行一个任务,而使用CountDownLatch就可以一个线程执行多个任务**。

java 复制代码
public class ThreadDemo39 {
    public static void main(String[] args) throws InterruptedException {
        // 1.此处构造方法中写10,意思是有10个线程任务
        CountDownLatch latch = new CountDownLatch(10);
        // 创建出 10个线程负责下载
        for (int i = 0; i < 10; i++) {
            int id = i;
            Thread t = new Thread(() -> {
                Random random = new Random();
                int time = (random.nextInt(5) + 1) * 1000;
                System.out.println("线程 "+id+"开始下载");
                try {
                    Thread.sleep(time);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程结束 " + id + "结束下载");
                // 2.告知 CountDownLacth 执行结束了
                latch.countDown();
            });
            t.start();
        }

        // 3. 通过await操作等待所有任务结束,也就是 countDown被调用10次
        latch.await();
        System.out.println("所有任务都已经完成了");
    }
}

8. ConcurrentHashMap

多线程环境下HashMap线程不安全,使用哈希表(Hashtable)就在关键方法上添加synchronized。

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

  • 介绍ConcurrentHashMap的锁分段技术
    把若干个哈希桶分成一个段,针对每个段分别加锁,这个是Java1.7中采取的技术,Java1.8不再使用

8.1 ConcurrentHashMap(Java1.8)的改进

  1. 缩小了锁的粒度
    在Hashtable,直接在方法上使用synchronized,就相当于是对this加锁,此时尝试修改两个不同链表上的元素,都会触发锁冲突 。如果修改两个不同链表上的元素,就不涉及到线程安全,修改不同变量。如果修改是同一个链表上的元素,就可能出现线程安全问题。
    ConcurrentHashMap 就是把锁变小了,给每个链表都发了一把锁,此时,不是操作同一个链表的锁,就不会产生锁冲突 。 不会产生更多的空间代价,因为Java中任何一个对象都可以直接作为锁对象,本身哈希表中,就得有数组,数组的元素都是已经存在的(每个链表的头节点作为加锁对象即可)。
    锁桶(hash表也称为hash桶),构成了一个类似于桶,每个链表就是构成桶的一个木板,所谓锁桶就是针对每个木板(每个链表)进行分别加锁的

  2. 充分的使用了CAS原子操作,减少一些加锁

  3. 针对扩容操作的优化扩容是一个重量操作。负载因子,描述了每个桶上平均有多少个元素,0.75是负载因子默认的扩容阈值,不是负载因子本体。负载因子是计算出来的:拿着实际的元素个数 / 数组的长度(桶的个数),这个值和扩容阈值进行比较,看是否需要扩容。

  • 如果太长,1.变成树(长度不平均的情况),2.扩容。
  • 创建一个更大的数组,把旧的hash表的元素都给搬运到(删除/插入)新的数组上,如果hash表本身元素非常多,这里扩容操作就会消耗很长的时间
  • ** HashMap的扩容操作是梭哈,在某次插入元素的操作中,整体就进行了扩容。而ConcurrentHashMap,每次操作都只搬运一部分元素。**
  • 即在扩容的过程中,同时存在两份哈希表,一份新的一份旧的:插入操作:直接往新的上插入;删除操作:新的旧的都是直接删除;查找操作:新的和旧的都得查询一下
相关推荐
顾北川_野1 分钟前
Android 手机设备的OEM-unlock解锁 和 adb push文件
android·java
江深竹静,一苇以航4 分钟前
springboot3项目整合Mybatis-plus启动项目报错:Invalid bean definition with name ‘xxxMapper‘
java·spring boot
真忒修斯之船4 分钟前
大模型分布式训练并行技术(三)流水线并行
面试·llm·aigc
confiself19 分钟前
大模型系列——LLAMA-O1 复刻代码解读
java·开发语言
Wlq041524 分钟前
J2EE平台
java·java-ee
ZL不懂前端25 分钟前
Content Security Policy (CSP)
前端·javascript·面试
XiaoLeisj31 分钟前
【JavaEE初阶 — 多线程】Thread类的方法&线程生命周期
java·开发语言·java-ee
阑梦清川33 分钟前
JavaEE初阶---网络原理(五)---HTTP协议
网络·http·java-ee
豪宇刘1 小时前
SpringBoot+Shiro权限管理
java·spring boot·spring
Elaine2023911 小时前
02多线程基础知识
java·多线程