多线程进阶

一:线程池

1.线程池的由来

本来多进程就是为了解决并发编程的方案,但是进程有点太重量了(创建和销毁,开销比较大)

因此引入了线程,线程比进程要轻量很多。

即便如此,如果某些场景中,需要频繁的创建销毁线程,此时,线程的创建和销毁的开销,也就无法被忽视了。线程池就是为了解决这样的问题而来。

2.线程池的使用
使用线程的时候,不是说用的时候才创建,而是提前创建好,放到一个“池子里”(和字符串常量池是类似的东西)

当我们需要使用线程的时候,直接从池子里取一个线程过来。
当我们不需要这个线程的时候,就把这个线程还回池子中。

如果是真的创建/销毁线程,涉及到用户态和内核态的切换。(切换到内核态,然后创建出对应的PCB来)
如果不是真的创建销毁线程,而只是放到池子里,就相当于全在用户态,就能搞定这个事情。

啥是用户态和内核态?
用户态是应用程序执行的代码。
内核态是操作系统内核执行的代码。
一般认为,用户态和内核态之间的切换,是一个开销较大的操作。

3.线程池 ThreadPoolExecutor

在Java标准库里面,也提供了现成的线程池组件。

ThreadPoolExecutor

ThreadPoolExecutor 里面包含的线程的数量并不是一成不变的。
能够根据任务量来自适应。
如果任务比较多,就会多创建一些线程。
如果任务比较少,就少创建一些线程。

经典面试题:ThreadPoolExecutor的构造参数都是啥意思
corePoolSize:核心线程池
maximumPoolSize:最大线程数

这两个值具体设成多少合适?
通过实验的方法来确定比较合适。

keepAliveTime:描述非核心线程存在的时间。

unit:时间单位,也是keepAliveTime的单位 ms,s,minute

本质是性能和资源之间做权衡。

workQueue:阻塞队列,来组织线程池要执行的任务。

threadFactory:线程的创建方式

RejectedExecutionHandler:拒绝策略。根据具体的业务场景,来选取具体的拒绝策略,用于处理极端情况。
丢弃最新的任务,丢弃最老的任务,阻塞等待,抛出异常。

4.线程池 Executors 类
由于 ThreadPoolExecutor 使用起来比较复杂,标准库又提供了一组其他的类,相当于对 ThreadPoolExecutor 又进行了一层封装。

这个类相当于一个“工厂类”
通过这个类提供的一组工厂方法,就可以创建出不同风格的线程池实例了。

  1. newFixedThreadPool:创建出一个固定线程数量的线程池(完全没有非核心线程)
  2. newCachedThreadPool:创建出一个数量可变的线程池(全是非核心线程)
  3. newSingleThreadPool:创建出一个只包含一个线程的线程池(只是特定场景下使用)
  4. newScheduleThreadPool:能够设定延时时间的线程池(插入的任务能够过一会再执行),相当于进阶版的定时器。

在以上几个工厂方法里面,调用了 ThreadPoolExecutor 的构造方法,同时把对应的参数进行了传递,并返回 ThreadPoolExecutor 实例。

public static void main(String[] args) {
        // 使用一下标准库中的线程池
        ExecutorService service = Executors.newFixedThreadPool(10);
        // 给这个实例里面加入一些任务
        for (int i = 0; i < 20; i++) {
            service.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello");
                }
            });
        }
    }

当前线程池里有10个工作线程
往任务队列中加入20个任务
此时这10个工作线程就会从任务队列中,先取出10个任务,然后并发执行这些任务
这些线程谁执行完了,当前的任务,谁就去任务队列中重新取一个新的任务。
直到把线程池任务队列中的任务都取完了,此时线程池的工作线程就阻塞等待(等待新的任务的到来)

5.工厂模式
工厂模式存在的意义就是在给构造方法填坑(定性);例如,平面坐标系和极坐标系的构造函数。
工厂模式主要是为了创建实例

构造方法的限制比较多

  1. 构造方法的名字,必须是固定的(类名)
  2. 如果需要多个版本的构造方法,就只能依赖构造方法的重载

为了解决构造方法留下来的坑:
不使用构造方法来构造实例了,而是使用其他的方法来进行构造实例,这样的用来构造实例的方法,就称为“工厂方法”。
工厂方法其实就是普通的方法,这个工厂方法里面会调用对应的构造方法,并进行一些初始化操作,并返回这个对象的实例。

6.实现一个简单版本的线程池

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class ThreadDemo25 {
    static class Worker extends Thread{
        private BlockingQueue<Runnable> queue = null;
        public Worker(BlockingQueue<Runnable> queue){
            this.queue = queue;
        }

        @Override
        public void run() {
            // 工作线程的具体的逻辑
            // 需要从阻塞队列中取任务
            while (true){
                try {
                    Runnable command = queue.take();
                    // 通过 run 来执行这个具体的任务
                    command.run();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    static class Threadpool{
        // 包含一个阻塞队列,用来组织任务
        private BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();

        // 这个 list 就用来存放当前的工作线程
        private List<Thread> workers = new ArrayList<>();

        private static final int MAX_WORKER_COUNT = 10;

        // 通过这个方法,把任务加入到线程池中
        // submit 不光可以把任务放到阻塞队列中,同时也可以负责创建线程
        public void submit(Runnable command) throws InterruptedException {
            if(workers.size() < MAX_WORKER_COUNT){
                // 如果当前工作线程的数量不足线程数目上限,就创建出新的线程
                // 工作线程就专门搞一个类来完成
                // worker 内部要能够取到队列的内容,就需要把这个队列实例通过 worker 的构造方法,传过去
                Worker worker = new Worker(queue);
                worker.start();
                workers.add(worker);
            }
            queue.put(command);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Threadpool pool = new Threadpool();
        for (int i = 0; i < 10; i++) {
            pool.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("hello");
                }
            });
        }
    }
}

二:常见的锁策略

加锁,是一个开销比较大的事情。
我们希望在一些特定场景下,针对场景做出一些取舍,好让锁更高效一些。

1.乐观锁和悲观锁

乐观锁:假设锁冲突的概率比较低,甚至与基本没有冲突。
就只是简单处理一下冲突。

悲观锁:假设锁冲突的概率比较高,甚至于每次尝试加锁都会有冲突。
此时就会愿意付出更多的成本来处理冲突。

synchronized其实就是悲观锁为主(也不全是,也有的时候是乐观锁)

乐观锁的典型实现:引入“版本号”对余额进行修改,更轻量。

2.读写锁

多个线程同时尝试修改同一个变量~线程不安全
如果多个线程同时读取同一个变量~线程安全

有些场景中,本来就是 写 比较少,读 比较多的情况

两个读线程,其实不存在线程安全问题,就不必互斥。
两个写线程之间,其实存在线程安全问题,就需要互斥。
一个读线程一个写线程之间,其实也存在线程安全问题,也需要互斥。

因此,就可以根据读写的不同场景,给读和写分别加锁。

synchronized 没有对读写进行区分,只要使用就一定互斥了。

Java标准库里提供了一个类
ReentrantReadWriteLock.ReadLock 能够构造一个读锁实例。
ReentrantReadWriteLock.WriteLock 能够构造一个写锁实例

对于读操作比较多,写比较少的情况,使用读写锁,就能大大的提高效率。(降低锁冲突的概率)

3.重量级锁和轻量级锁

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

重量级锁:加锁解锁开销很大,往往是通过内核来完成的。
轻量级锁:加锁解锁开销很大,往往是只是在用户态完成的。

看重的是加锁解锁的开销大不大,和应用场景没啥关系。

加锁这里的“互斥”能力,是哪里来的?
归根结底,是CPU的能力~CPU提供了一些特殊的指令,通过这些指令来完成互斥。
操作系统内核在对这些指令进行了封装,并实现了阻塞等待。

CPU提供了一些特殊指令(原子操作的指令)
操作系统对这些指令封装了一层,提供了一个 mutex(互斥量)

Java JVM 相当于对操作系统提供的 mutex 再封装一层,实现了 synchronized 这样的锁。

如果是当前的锁,就是通过内核的 mutex 来完成的,此时这样的锁往往就开销比较大。
如果当前的锁,是在用户态,通过一些其他的手段来完成,这样的锁往往就开销更小。

synchronized 既是一个轻量级锁,也是一个重量级锁,根据场景自动适应。

锁策略面试题常见问法:谈谈对×××锁的理解

4.自旋锁和挂起等待锁

自旋锁:如果线程获取不到锁,不是阻塞等待,而是循环的快速的再试一次,因此就节省了操作系统调度线程的开销,要比挂起等待锁更能及时的获取到锁。问题是更浪费CPU资源。

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

啥时候使用挂起等待锁,啥时候使用自旋锁?
大的原则是:

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

这个自旋和挂起等待,这样的策略在 synchronized 中内置,自适应。

5.公平锁和非公平锁

对于公平锁来说,追求先来后到的原则,先等待的线程先获得锁。要想实现公平锁,就需要有额外的数据结构(比如有个队列,通过这个队列来记录这个先来后到的过程)

对于非公平锁来说,不遵守先来后到的规则,获取锁的概率是均等的,完全取决于操作系统的调度了。

选择哪一个锁看需求选择:
大部分情况下,使用非公平锁就够了。
有些场景下,我们期望对于线程的调度的时间成本是可控的,这个时候就更需要公平锁了。

synchronized 是非公平锁

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

如果针对同一把锁,连续加锁两次,
如果是不可重入锁,就会出现“死锁”
如果是可重入锁,就不会死锁。

让当前的锁,记录一下这个锁是谁持有的,如果发现,当前有一个线程再次尝试获取锁,这个时候,就让代码能够运行,而不是阻塞等待。同时在这个锁里也维护一个计算器。

synchronized 就是可重入锁

三:CAS

1.什么是CAS

compare and swar,字面意思是“比较和交换”,原子操作。

2.CAS的应用—实现原子类,实现自旋锁

像 i++ 这样的操作是线程不安全的。如果需要多线程并发的 i++,就需要加锁。
加锁操作是比较低效的,因此使用 CAS 就可以既能够高效的完成自增,同时又能够保证线程安全。(高效+安全)

基于CAS这样的操作,就实现出了一些“原子类”,这些原子类都是不需要加锁就能保证线程安全。

3.CAS 中的 ABA 问题

使用 CAS 的时候无法区分这个数据是始终没变,还是这个数据从A 变成了B,又变回了A。
大部分情况下,ABA问题,其实影响不大。

描述 ABA导致的 bug,导致误判。

如何解决 ABA 问题?

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

四:synchronized 的原理

结合之前讲到的锁策略,可以对synchronized做一个直观的认识

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

synchronized的锁升级

偏向锁,其实也就是一种乐观锁,本质上是一种“延时加锁”。

偏向锁只是在对象头重设置一个“偏向锁标记”,这个只做标记,就比真正的加锁,要高效很多。
如果赌赢了,那么这次就不真加锁了,直到锁的释放,这整个过程中根本没有涉及到加锁解锁,因此就很快
如果赌输了,比如线程1尝试获取这把锁,锁进入偏向锁状态,此时如果有一个线程2,也尝试竞争这个锁,那么此时线程1就会抢先把这个锁拿到,然后线程2去等待。

完全没竞争的时候,是偏向锁,如果出现了竞争,但是这时候竞争还比较小,此时我们就会进入到“轻量级锁”状态。

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

但是如果当前的场景,是锁冲突概率比较大,锁的竞争比较激烈。此时锁还会进一步的膨胀成重量级锁

如果锁冲突的概率太大了,轻量级自旋锁,就会浪费大量的CPU(在等待的时候是CPU空转的)

使用更重量的挂起等待锁,就可以解决这个问题。
对于挂起等待锁来说,当锁等待的过程中,是释放CPU(不占用CPU)
代价就是引入了线程的阻塞和调度开销

这些过程的转变,完全是自适应。

synchronized 除了这个自适应的锁升级之外,还有一些重要的优化手段。

锁消除,其实就是编译器和JVM自行判定一下,看看当前这个代码是否真的需要加锁。(JVM和编译器,不相信程序猿的水平)

锁的粗化
JVM和编译器,会进行一些智能的判定,把多组synchronized合并为一组。
锁的粒度
synchronized 代码中包含多少代码,如果包含的代码多,认为锁的粒度比较粗。如果包含的代码少,认为锁的粒度比较细。

五:Callable

关于线程创建:

  1. 继承Thread,重写run
  2. 实现Runnable 重写run
  3. 继承Thread,重写run,使用匿名内部类
  4. 实现Runnable,重写run,使用匿名内部类
  5. 使用lambda表达式

Callable也是一种创建线程的方式

Runnable只是描述了一个过程,不关注结果(不关注返回值)
Callable也是描述了一个过程,同时要关注返回结果
Callable中包含一个call方法,和Runnable.run类似,都是描述一个具体的任务,但是call方法是带返回值的。

如果我们期望创建一个线程,并关注这个线程产生的返回结果,使用Callable就比较合适。

public class ThreadDemo26 {
    static class Result{
        public int sum;
        public Object locker = new Object();
    }

    public static void main(String[] args) throws InterruptedException {
        Result result = new Result();
        Thread t = new Thread(){
            @Override
            public void run() {
                int sum = 0;
                for (int i = 0; i <= 1000 ; i++) {
                    sum+=i;
                }
                result.sum = sum;

                synchronized (result.locker) {
                    result.locker.notify();
                }
            }
        };
        t.start();

        // 此处我们期望,这个线程的计算结果能够被主线程获取到
        // 为了解决这个问题,就需要引入一个辅助的类
        // 当代码写成这个样子的时候,发现在主线程中,是无法得到 sum 的值的
        // 主要是因为当前 t 线程和主线程之间是并发的关系
        // 执行的先后顺序不能确定
        // 解决方案是,让main这个线程先等待(wait)。t线程计算完毕之后,通知唤醒 main 线程即可
        synchronized (result.locker) {
            while (result.sum == 0){
                result.locker.wait();
            }
        }
        System.out.println(result.sum);
    }
}

此时我们需要使用wait notify 以及 sybchronized 这些机制相互配合,才能完成这个工作。

使用Callable接口,会更加方便。

package java100_0926;

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

public class ThreadDemo27 {
    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 = 0; i <= 1000 ; i++) {
                    sum+=i;
                }
                return sum;
            }
        };
        // 由于 Thread 不能直接传一个 callable 实例,就需要一个辅助的类来包装一下
        // futureTask保存了Callable返回的结果
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();

        // 尝试在主线程获取结果
        Integer result = futureTask.get();
        System.out.println(result);
    }
}

介绍下Callable是什么?

  • Callable 是一个 interface . 相当于把线程封装了一个 “返回值”. 方便程序猿借助多线程的方式计算
    结果.
  • Callable 和 Runnable 相对, 都是描述一个 “任务”. Callable 描述的是带有返回值的任务,
    Runnable 描述的是不带返回值的任务.

Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为
Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.
FutureTask 就可以负责这个等待结果出来的工作.

六:JUC(java.util.concurrent) 的常见类(面试出现的不多)

concurrent 并发

1.ReentrantLock

		public void increase(){
            lock.lock();
            count++;
            lock.unlock();
        }

ReentrantLock把加锁和解锁操作拆分开了
这种风格的代码,是常见的写法。

import java.util.concurrent.locks.ReentrantLock;

public class ThreadDemo28 {
    static class Counter{
        public int count = 0;
        public ReentrantLock lock = new ReentrantLock();
        public void increase(){
            lock.lock();
            count++;
            lock.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);
    }
}

ReentrantLock相当于对 synchronized进行了补充

  1. ReentrantLock把加锁和解锁拆分成两个方法,确实存在解锁的风险,但是可以让代码更灵活,比如把加锁解锁的diamante分别放到两个方法中
  2. ReentrantLock除了lock和unlock方法之外,还提供了一个tryLock方法;
    对于lock方法来说,如果尝试加锁失败,就会阻塞等待
    对于tryLock方法来说,如果尝试加锁失败,就直接返回出错,不会阻塞等待
  3. synchronized是一个非公平锁;
    ReentrantLock支持两种模式:既可以支持公平锁,也能支持非公平锁
public ReentrantLock lock = new ReentrantLock(true);
  1. ReentrantLock提供了比synchronized更强大的等待唤醒机制
    synchronized是搭配wait notify
    ReentrantLock是搭配了另外一个Condition类来完成等待唤醒(能够显示指定唤醒那个等待的线程)

2.原子类

  • AtomicBoolean
  • AtomicInteger
  • AtomicIntegerArray
  • AtomicLong
  • AtomicReference
  • AtomicStampedReference
package java100_0926;

import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.ReentrantLock;

public class ThreadDemo28 {
    static class Counter{

        //public ReentrantLock lock = new ReentrantLock(true);
        public AtomicInteger count = new AtomicInteger(0);
        public void increase(){
//            lock.lock();
//            count++;
//            lock.unlock();
            count.getAndIncrement();
        }
    }
    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);
    }
}

3.信号量 Semaphore

信号量是一个计算器(int整数)
功能:描述可用资源的个数

也可以使用信号量来控制线程安全。
创建信号量的时候,设置一个初始值(可用资源的个数)
如果把初始值设为1了,此时这个信号量就只有0 1两种取值
称为“二元信号量”
二元信号量就和锁的功能是类似的

P操作:申请资源
V操作:释放资源

import java.util.concurrent.Semaphore;

public class ThreadDemo29 {
    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 1000ms
                    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

import java.util.concurrent.CountDownLatch;

public class ThreadDemo30 {
    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("比赛结束");
    }
}

七:线程安全的集合类

线程不安全的集合类

  • ArrayList
  • LinkedList
  • TreeMap
  • HashMap
  • TreeSet
  • HashSet
  • Queue

如果想在多线程环境下来使用这些,就需要做一些特殊的处理~典型的办法就是加锁。

Stack String StringBuffer 是线程安全
Strng 内部并没有进行加锁,但是仍然是线程安全?
String是一个不可变对象,指的就是,没有提供public的方法,来修改 String的内容。

多线程环境下使用哈希表

ConcurrentHashMap 其实也是使用 synchronized 加锁,但是加锁方式和 HashTable 区别很大。
HashTable 是直接针对 this 对象来加锁
一个HashTable实例,就只有一把锁
此时如果有10个线程并发修改这个HashTable,此时10个线程也就都在竞争同一把锁了(锁冲突的概率比较高)

ComcurrentHashMap锁对象,是针对数组的每个元素来进行加锁(也就是针对每个哈希桶来加锁)

此时如果再有十个线程,并发修改哈希表,此时如果当前线程之间计算出的 数组位置(hashcode%数组长度)是不相同的,此时就没有锁竞争。
即使有两个线程修改的元素正好在同一个数组位置上,此时才会发生锁竞争。

1.ConcurrentHashMap 针对修改操作的加锁,使用的是粒度更小的锁,针对每个哈希桶来分别设定锁。大大降低了锁冲突的概率(针对java1.8)
在java1.8之前,ConcurrentHashMap使用的是“分段锁”,相当于把这些哈希桶分成若干组,每组分配一个锁。
2.ConcurrentHashMap针对读操作,没加锁,而是直接使用volatile
设计者评估了说,读操作,其实影响不大,读到一个旧的值和新的值,对于实际开发来说没有明显的影响
3.更充分的利用了CAS的特性,比如获取/修改size属性(元素个数)
4.更优化的扩容方式

HashTable的扩容:
如果某次put操作,导致当前的元素个数太多了,就会触发扩容,这个扩容就会需要创建一个更大的内存,并且把数据复制过去一份。
这就会直接导致这次插入操作,非常非常低效。

ConcurrentHashMap 基本思路,就是“化整为零”
如果某个插入操作触发了扩容,不是一口气扩容完,而是只搬运一部分元素。
下次再对这个ConcurrentHashMap操作的时候,再搬运一部分,保证每次操作都不至于太慢
在这个搬运过程中,相当于内部维护了两套内存,一套是旧的数据,一套是新的数据
插入操作,就只往新数据中插
查找操作,就需要同时查旧的数据和新的数据
当完全搬运完成,再删除旧数据

八:死锁

死锁:锁导致了线程被阻塞,该锁被释放了之后,对应的线程才会结束阻塞,但是有的时候可能这个锁永远也释放不了。于是该线程也就无法结束阻塞状态了。

产生死锁的场景:
1.如果一个线程针对一把锁,连续尝试加锁两次,并且该锁不是可重入锁的时候。
2.两个线程,两把锁(双方互不释放资源,仍然需要对象的另一把锁)
3.多个线程和多个锁 =》哲学家问题

站在教科书的角度,总结出死锁的四个必要条件:
1.互斥使用。如果一个锁被一个线程占用的时候,别的线程就会阻塞等待(锁的基本设定)
2.不可抢占/不可剥夺。线程1如果获取到一把锁,此时线程2不能强行把锁给抢过来
3.请求和保持。当资源的请求者在请求其他资源的时候,同时要保持之前的资源(线程获取到锁1之后,再尝试获取锁2,此时仍然保持对锁1的持有)
4.循环等待。线程1,先尝试获取锁1 和锁2 线程2 尝试获取锁2和锁1 这样的情况就是循环等待。

重点记第四条,和写代码相关。

死锁的常见面试题:谈谈对于死锁的理解
不要背!!!

如何避免死锁?

银行家算法非常复杂,不太适合实际开发的时候使用。

站在开发的角度:
1.尽量避免复杂的设计,避免在某个锁的代码中再尝试获取其他锁。
如果实在需要进行锁的嵌套使用,一方面要保证持有锁的时间足够短,代码足够简单,另一方面要保证按照统一的顺序(先获取锁1 再获取锁2)来进行加锁。

这样的固定顺序,其实就是破坏了死锁的“循环等待”

你可能感兴趣的:(JavaWeb,多线程,java)