多线程--进阶

目录

一. 常见的锁策略

1.1 乐观锁 vs 悲观锁

1.2 轻量级锁 vs 重量级锁

1.3 自旋锁 vs 挂起等待锁

1.4 互斥锁 vs  读写锁

1.5 可重入锁 vs 不可重入锁

1.6 公平锁 vs 非公平锁

二. 死锁问题

三. CAS

3.1 CAS 伪代码  

3.2 CAS 是怎么实现的 

3.3 CAS有哪些应用?

1. 实现原子类

2.实现自旋锁

3.4 CAS的 ABA 问题

1. 什么是 ABA问题

2. ABA 问题引来的 BUG 

3. 解决方案 

3.4 相关面试题 

1. 讲解下你自己理解的CAS 机制

2. ABA问题怎么解决?

四. Synchronized 原理 

4.1 偏向锁

 4.2 轻量级锁

 4.3 重量级锁

4.4 其他优化操作

 锁消除

锁粗化

五. Callable 接口

5.1 Callable 的用法

5.2 理解 Callable

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

6.1 ReentrantLock

6.2 原子类

6.3 信号量 Semaphore

6.4 CountDownLatch

6.5 线程安全集合类

 1.多线程环境使用 ArrayList

2. 多线程环境使用队列

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

七. 其他常见问题 


一. 常见的锁策略

1.1 乐观锁 vs 悲观锁

预测接下来锁        冲突的概率是大,还是不大,根据这个冲突的概率,来决定接下来该咋做.

悲观锁:预测接下来冲突的概率比较大,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以在每次拿数据的时候都会上锁,这样别人想拿这个数据的时候就会阻塞直到它拿到锁

乐观锁:预测接下来的冲突的概率不大,假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则返回用户错误的信息,让用户决定如何去做.

通常来说: 悲观锁一般要做的工作更多一些,效率会更低一些,乐观锁做的工作会少点,效率更高一点。

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

1.2 轻量级锁 vs 重量级锁

 重量级锁:加锁机制重度依赖了 OS提供的 mutex

大量的内核态用户态切换

很容易引发线程的调度

这两个操作,成本比较高,一旦涉及到用户态和内核态的切换,就意味着“沧海桑田”。

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

• 少量的内核态用户态切换

• 不太容易引发线程调度

理解用户态 vs 内核态

想象成去银行办业务

在窗口外,自己做,这时用户态,自己需要的时间成本是可控的;

在窗口内,工作人员做,这是内核态,内核态的时间成本是不太可控的。‘

如果办业务的时候反复和工作人员沟通,还需要重新排队,这时效率时很低的。

synchronized 开始是一个轻量级锁,如果锁冲突比较严重,就会变成重量级锁。

1.3 自旋锁 vs 挂起等待锁

  按之前的方式,线程抢锁失败后进入阻塞状态,放弃CPU,需要过很久才能再次被调度,但实际上,大部分情况下,虽然当前抢锁失败,但过不了多久,锁就会被释放,没有必要就放弃cpu,这个时候就可以使用自旋锁来处理这样的问题。

自旋锁伪代码:

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

如果获取锁失败,立即再尝试获取锁,无限循环,直到获取到锁为止。第一次获取锁失败,第二次的尝试会在极短的时间内到来。

一旦锁被其他线程释放,就能第一时间获取到锁。

理解自旋锁 vs 挂起等待锁

想象一下,去追求喜欢的女神,当男生向女生表白后,女神说:你是个好人,但是我已经有男朋友了~~

挂起等待锁:我就不搭理女神了~~,我去潜心敲代码,等到未来某一天 ,女神分手了,她想起我来了,她主动找我,这个时候,我的机会就来了!!在挂起等待的期间,如果锁被释放,不能第一时间拿到锁,可能要过很久才能拿到锁,这个时间我是空闲出来的,就可以趁机学点别的技能。

自旋锁是一种典型的轻量级锁的实现方式(纯用户态的,不需要经过内核态,时间相对更短)

• 优点:没有放弃 CPU,不涉及线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁。

• 缺点:如果锁被其他线程持有的时间比较久,那么就会持续消耗 CPU 资源(而挂起等待的时候是不消耗 CPU 的)。

挂起等待锁是通过内核态的机制来实现挂起等待,会使获取锁的时间更长。

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

针对上述三组锁策略:

synchronized 既是悲观锁,也是乐观锁,既是轻量级锁,也是重量级锁,轻量级锁部分基于自旋锁实现,重量级锁部分基于挂起等待锁实现。

1.4 互斥锁 vs  读写锁

 synchronized 是互斥锁,像 synchronized 只有两个操作:

出了代码块,解锁

进入代码块,加锁 

对与 synchronized 的加锁,只是单纯的加锁,没有更细化的区分了

除了 synchronized 锁之外,还有一种 读写锁,能够把 读 和 写 两种加锁区分开

读写锁(ReentrantReadWriteLock):

1.读锁和读锁之间,不会锁竞争。不会产生阻塞等待。

2.写锁和读锁之间,有锁竞争

3.写锁和读锁之间,也有锁竞争

1.5 可重入锁 vs 不可重入锁

如果一个锁,在一个线程中,连续对该锁加锁两次,不死锁,就叫做可重入锁,如果死锁了,就叫做不可重入锁

synchronized 是可重入锁

class BlockingQueue{
    synchronized void put(int elem){
        this.size();
    }
    
    synchronized int size(){......}
}

synchronized 是可重入锁,在这个场景下,不会死锁。在加锁的时候会判定一下,看当前尝试申请锁的线程是不是已经就是锁的拥有者了!如果是,就直接放行。

1.6 公平锁 vs 非公平锁

公平锁:遵守“先来后到”。B比C先到,当A释放锁之后,B就能先于C获取到锁

非公平锁: 不遵守“先来后到”.B和C竞争锁,都有可能获取到锁。

操作系统内部的线程调度就可以视为是随机的。如果不做任何额外的限制。锁就是非公平锁。如果要想实现公平锁,就需要依赖额外的数据结构,来记录线程们的先后顺序

公平锁和非公平锁没有好坏之分,关键还是看使用场景。

在。

synchronized 是非公平锁

二. 死锁问题

死锁是什么?

多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放.由于线程被无限期的阻塞,因此程序不可能正常终止。

如何避免死锁

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

• 互斥使用,即当资源被一个线程使用时,别的线程不能使用

• 不可抢占,资源请求者不能强制从资源占有者手中读取资源,资源只能由资源占有者主动释放。

• 请求和保持,即当资源请求者在请求其他资源的同时保持对原有资源的占有。

• 循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P2的资源。这样就形成了一个等待循环路。

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

其中最容易破坏的就是“循环等待”。

破坏循环等待 

最常用的一种死锁阻止技术就是锁排序。假设有N个线程尝试获取M把锁,就可以针对M把锁进行编号(1,2,3...M).

N 个线程尝试获取锁的时候,都按照固定的编号由小到大顺序来获取锁。这样就可以避免环路等待

两个线程对于加锁顺序没有约定,就容易产生环路等待  

    public static void main(String[] args) {
        Object lock1=new Object();
        Object lock2=new Object();

        Thread t1=new Thread(()->{
            synchronized (lock1){
                synchronized (lock2){
                    //do something
                }
            }
        });
        t1.start();
        Thread t2=new Thread(()->{
            synchronized (lock2){
                synchronized (lock1){
                    //do something
                }
            }
        });
        t2.start();
    }

 不会产生环路等待的代码:

约定号先获取lock1, 再获取lock2,就不会产生环路等待。

    public static void main(String[] args) {
        Object lock1=new Object();
        Object lock2=new Object();

        Thread t1=new Thread(()->{
            synchronized (lock1){
                synchronized (lock2){
                    //do something
                }
            }
        });
        t1.start();
        Thread t2=new Thread(()->{
            synchronized (lock1){
                synchronized (lock2){
                    //do something
                }
            }
        });
        t2.start();
    }

三. CAS

 CAS(compare and swap): 寄存器A的值和内存M的值进行对比,如果值相等,就把寄存器B的值赋给内存M。

我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。

1.比较A与V是否相等(比较)

2.如果比较相等,将B写入V。(交换)

3.返回操作成功过。

3.1 CAS 伪代码  

下面写的代码不是原子的,真实的CAS是一个原子硬件指令完成的(这一条指令就能完成下述这一段代码)。这个伪代码只是辅助理解CAS的工作流程的。

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

当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。

CAS可以视为是一种乐观锁(或者可以理解为CAS是乐观锁的一种实现方式)

3.2 CAS 是怎么实现的 

针对不同的操作系统,JVM用到了不同的CAS实现原理,简单来讲:

• java的CAS利用的是 unsafe 这个类提供的CAS操作;

• unsafe 的CAS依赖的是 jvm针对不同的操作系统实现的 Atomic::cmpxchg;

• Atomic::cmpxchg 的实现使用了汇编的CAS操作,并使用cpu 硬件提供的lock机制保证其原子性。

简而言之,是因为硬件予以了支持,软件层面才能做到。

3.3 CAS有哪些应用?

1. 实现原子类

标准库中提供了 java.util.concurrent.atomic 包,里面的类都是基于这种方式来实现的。

典型的就是 AtomicInteger 类,其中的 getAndIncrement 相当于i++操作。

伪代码实现:

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 是一个局部变量,在栈上,每个线程都有自己的栈)

多线程--进阶_第1张图片

(2)线程1 先执行CAS操作,由于 oldValue 和 value 的值相同,直接进行对value赋值。

注意:

• CAS 是直接读写内存的,而不是操作寄存器

• CAS 的读内存,写内存操作的是一条硬件指令,是原子的。

 多线程--进阶_第2张图片

(3)线程2 再次执行CAS 操作,第一次CAS的时候发现oldValue 和value 不相等,不能直接赋值,因此需要进入循环。在循环里重新读取value的值赋给oldValue

 多线程--进阶_第3张图片

(4) 线程2 接下来第二次执行CAS,此时oldValue 和 value 相同,于是直接赋值操作

 多线程--进阶_第4张图片

(5)线程1 和线程2 返回各自的oldValue 的值即可。

再次声明:本来check and set 这样的操作在代码角度不是原子的,但是在硬件层面上可以让一条指令完成这个操作,也就变成原子的了

2.实现自旋锁

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

自旋锁伪代码

public class SpinLock{
    private Thread owner =null;
    
    public void lock(){
        //如果当前owner 是null,比较就成功,就把当前线程的引用设置到owner 中,加锁完成!!循环结束
        //比较不成功,意味着 owner 非空,锁已经有线程持有了,此时CAS就啥都不干,直接返回false,循环继续
        //此时这个循环就会转的飞快,不停地尝试询问这里的锁是不是释放了,好处是一旦锁释放,就能立刻获取到.坏处,CPU忙等
        while(!CAS(this.owner, null, Thread.currentThread()){
        }
    }
    public void unlock{
        this.owner =null;
    }
}

3.4 CAS的 ABA 问题

1. 什么是 ABA问题

ABA问题: 假设存在两个线程t1和 t2 ,有一个共享变量,初始值为 A.

接下来,线程t1 想使用 CAS把num 值改成Z,那么就需要

• 先读取 num的值,记录到oldNum 变量中.

• 使用CAS 判定num的值是否为A,如果为A,就修改成了Z.

但是,在t1 执行这两个操作之间,t2 线程可能把num 的值从A变成了B,有从B改成了A.当t1 使用oldNum的值和内存中的值进行比较时,就会发现值没有变,从而执行CAS指令,对num进行赋值.

2. ABA 问题引来的 BUG 

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

假设滑稽老哥有100 存款,滑稽想从ATM 50 块钱,取款机创建了两个线程,并发的来执行 -50 操作

我们期望一个线程执行 -50 成功,另一个线程执行-50 失败.如果使用CAS 的方式来完成这个扣款过程就可能出现问题

正常过程

(1) 存款为100, 线程1 获取到当前存款值为 100, 期望更新为 50;线程2 获取到当前存款为100,期望更行为50.

(2) 线程 1 执行扣款成功,存款被改成50 ,线程2 阻塞等待中.

(3) 轮到线程2 执行了,发现当前存款未 50,和之前读到的 100 不相同.执行失败.

异常过程

(1) 存款100, 线程1 获取到当前存款值为100, 期望更新为50; 线程2 获取到当前存款为100,期望更新为50

(2) 线程1执行扣款成功,存款被改成 50, 线程2 阻塞等待中

(3) 咋线程2 执行之前,滑稽的朋友正好给滑稽转账50, 账户余额变成100

(4) 轮到线程2 执行了, 发现当前存款为 100, 和之前读到的100 相同,再次执行扣款操作

这个时候,扣款操作被执行了两次,都是ABA问题搞得鬼.

3. 解决方案 

要给修改的值,引入版本号, 在CAS 比较数据当前值和旧值的同时,也要比较版本号是否符合预期.

• CAS 操作在读取旧值的同时,也要读取版本号

• 真正修改的时候:如果当前版本号和读到的版本号相同,则修改数据,并把版本号 +1;如果当前版本号低于读到的版本号,就操作失败(认为数据已经被修改过了)

同样以上面的例子为例

假设滑稽老哥有 100 存款, 滑稽想从 ATM 取50 块钱. 取款机创建了两个线程,并发的来执行-50操作.

我们期望一个线程执行-50成功后,另一个线程-50失败

为了解决ABA 问题,给余额搭配一个版本号,初始设为1.

(1) 存款100, 线程1 获取到存款值为100,版本号为1, 期望更新为 50; 线程2获取到存款值为100,版本号为1,期望更新为50.

(2) 线程1 执行扣款成功,存款被改成50, 版本号改为2, 线程2 阻塞等待中

(3) 咋线程 2 执行之前,滑稽的朋友正好给滑稽转账50, 账户余额变成100, 版本号变为3

(4) 轮到线程2 执行了,返现当前存款为100,和之前读到的 100 相同,但是当前版本号为3,之前读到的版本号为1 ,版本小于当前版本,认为操作失败.

3.4 相关面试题 

1. 讲解下你自己理解的CAS 机制

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

2. ABA问题怎么解决?

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

四. Synchronized 原理 

基本特点:结合上面的锁策略,我们可以总结出, Synchronized 具有以下特性(只考虑JDK 1.8)

1. 开始时是乐观锁,如果锁冲突频繁,就转换为重量级锁

2. 开始时是轻量级锁,如果锁被持有的时间较长,就转换为重量级锁

3. 实现轻量级锁的时候大概率用到的是自旋锁策略

4. 是一种不公平锁

5. 是一种可重入锁

6. 不是读写锁

加锁工作过程

JVM将 synchronized锁分为无锁,偏向锁,轻量级锁,重量级锁。会根据做情况,进行依次升级 

4.1 偏向锁

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

偏向锁不是真的“加锁”,只是给锁对象中做一个“偏向锁”的标记,记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁,那么就不用进行其他同步操作了(避免了加锁解锁的开销)如果后续有其他线程来竞争锁(刚才已经在锁对象中记录了当前锁属于哪个线程了,很容易识别当前申请锁的线程是不是之前记录的线程),那就取消原来的偏向锁状态,进入一般的轻量级锁状态。

偏向锁本质上相当于“延迟加锁”,能不加锁就不加锁,尽量避免不必要的加锁和开锁,但是还是要做标记的,否则无法区分何时需要真正的加锁

 4.2 轻量级锁

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

• 通过CAS检查并更新一块内存(比如检锁对象做“偏向锁”标记的内存部分是否为null,如果为null,更新为该对象的引用)

• 如果更新成功,则认为加锁成功

• 如果更新 失败,则认为该锁被占用,继续自旋式等待

自旋操作是一直让CPU 空转,比较浪费CPU资源。

因此此处的自旋不会一直进行,而是达到一定的时间/重试次数,就不再自旋了

也就是所谓的“自适应”

 4.3 重量级锁

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

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

• 执行加锁操作,先进入内核态

• 在内核态判定当前锁是否已经被占用

• 如果该锁没有被占用,则加锁成功,并切换回用户态

• 如果该锁被占用,则加锁失败。此时线程进入锁的等待队列,挂起,等待被操作系统唤醒

• 经历了一系列的沧海桑田,这个锁被其他线程释放了,操作系统也想起了这个挂起的线程,于是唤醒这个线程,尝试重新获取锁

4.4 其他优化操作

 锁消除

编译器+JVM 判断锁是否可消除。如果可以,编译阶段做的优化手段,检查当前代码是否是多线程/是否有必要加锁,如果无必要,有把锁给写了,就会在编译过程中自动把锁去掉。

什么是“锁消除”?

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

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

 此时每个append的调用都会涉及到加锁和解锁,但如果只是在单线程中执行这个代码,那么这些加锁解锁操作是没有必要的,白白浪费了一些资源开销。此时编译器就会做出优化,判断是否真的需要加锁。

锁粗化

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

锁的粒度:synchronized 代码块,包含代码的多少,(代码越多,粒度越粗,越少,粒度越细。

实际开发过程中,使用细粒度锁,是期望释放锁的时候其他线程能使用,减少串行。

但是如果某个场景,要频繁加锁/解锁,此时编译器就可能把这个操作优化成一个粗粒度的锁。(加锁解锁要消耗大量的CPU资源)

五. Callable 接口

5.1 Callable 的用法

 public static void main(String[] args) throws ExecutionException, InterruptedException {
        //此时的泛型参数表示返回值类型
        Callable callable=new Callable() {
            @Override
            public Integer call() throws Exception {
                int sum=0;
                for (int i = 0; i <=1000; i++) {
                    sum+=i;
                }
                return sum;
            }
        };
        // 把callable 实例使用FutureTask包装一下,不能直接传callable 
        FutureTask futureTask=new FutureTask<>(callable);
        //创建线程,线程的构造方法传入FutureTask.此时新线程就会执行FutureTask内部的Callable 的call方法,完成计算,计算结果就放到了FutrueTask对象中
        Thread t=new Thread(futureTask);
        t.start();
        //主线程中调用futrue.get() 能够阻塞等待新线程计算完毕,并获取到FutrueTask中的结果
        int sum=futureTask.get();
        System.out.println(sum);
    }

5.2 理解 Callable

Callable 和 Runnable 相对,都是描述一个“任务”,Callable 描述的是带有返回值的任务,Runnable 描述的是不带返回值的任务。

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

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

6.1 ReentrantLock

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

ReentrantLock 的用法

• lock():加锁,如果获取不到锁就死等

• trylock():加锁,如果获取不到锁,等待一定的时间后就放弃锁

• unlock() : 解锁

ReentrantLock lock=new Reentrant();
lock.lock()
try{
   //working
}finally{
   lock.unlock();
}

ReentrantLock 和 synchronized 的区别:

1.synchronized 是一个关键字,是JVM 内部实现的,(大概率是基于C++实现的)。ReentrantLock 是标准库中的一个类,在JVM外实现的(基于java 实现的)。

2.sychronized 只是加锁和解锁,加锁的时候如果发现锁被占用,只能阻塞等待。ReentrantLock 还提供了一个tryLock 方法,如果加锁成功,那就没啥特殊的,如果加锁失败,不会阻塞,直接返回false(让程序员更灵活的决定接下来该怎么做)。

3. synchronized 是一个非公平锁(不遵循先来后到);ReentrantLock 提供了公平和不公平两种工作模式。(在构造方法中,传入true 开启公平锁)

4. synchronized 搭配wait/notify 进行等待唤醒。如果多个线程wait 同一个对象,notify 的时候是随机唤醒一个。ReentrantLock 则是搭配Condition 这个类,这个类也能起到等待通知,但是可以精确的唤醒某个指定的线程。

6.2 原子类

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

• AtomicBlloean

• AtomicInteger

• AtomicIntegerArray

• AtomicLong

• AtomicReference

• AtomicStampdeReference

以AtomicInteger 举例,常见的方法有

addAndGet(int delta);       i+=delta;

decrementAndGet();         --i;

getAndDecrement();         i--;

incrementAndGet();          ++i;

getAndIncrement();           i++; 

6.3 信号量 Semaphore

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

理解信号量

可以把信号量想象成是停车场的展示牌:当前有100个,表示有100个可用资源。

当有车开进来的时候,就相当于申请一个可用资源,可用车位就-1(称为信号量的P操作)

当有车开出去的时候,就相当于释放一个可用资源,可用车位就+1(称为信号量的V操作)

如果计数器的值已经为0了,还尝试申请资源,就会阻塞等待,直到有其他线程释放资源。

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

    public static void main(String[] args) {
        Semaphore semaphore=new Semaphore(5);
        for (int i = 0; i < 20; i++) {
            int num=i;
            Thread t=new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        System.out.println("申请资源");
                        semaphore.acquire();
                        System.out.println("我申请到了资源"+num);
                        Thread.sleep(1000);
                        System.out.println("我释放资源了"+num);
                        semaphore.release();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                }
            });
            t.start();
        }
    }

6.4 CountDownLatch

同时等待N个任务结束。

好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。(线程都执行完,才执行后面的代码)。

 public static void main(String[] args) throws InterruptedException {
        CountDownLatch countDownLatch=new CountDownLatch(10);
        Runnable runnable=new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep((long)(Math.random()*1000));
                    //执行完毕,调用countDown()
                    countDownLatch.countDown();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        };
        for (int i = 0; i < 10; i++) {
            Thread t=new Thread(runnable);
            t.start();
        }
        //阻塞等待所有任务完成,此时会暗中计算,有几个countDown被调用了,
        // 当这10个人都调用过了之后,此时主线程的await就阻塞解除,就可以进行接下来的工作了。 
        countDownLatch.await();
        System.out.println("执行完毕");
    }

6.5 线程安全集合类

 1.多线程环境使用 ArrayList

(1) Collection.synchronizedList(new ArrayList);

synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的List.

synchronizedList 的关键操作上都带有synchronized

(2) 使用 CopyOnWriteArrayList

CopyOnWrite 容器即写时复制容器.

• 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后在新的容器里添加元素.

• 添加玩元素之后,再将原容器的引用指向新的容器

这样做的好处是我们可以对CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素.所以CopyOnWrite 容器也是一种读写分离的思想,读和写不同的容器。

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

缺点:占用内存多;新写的数据不能第一时间读取到。

2. 多线程环境使用队列

(1)ArrayBlockingQueue

基于数组实现的阻塞队列

(2)LinkedBlockingQueue

基于链表实现的阻塞队列

(3)PriorityBlockingQueue

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

(4)TransferQueue

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

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

HashMap 本身不是线程安全的

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

• Hashtable

• ConcurrentHashMap

(1) Hashtable

只是简单的把关键方法加上了 synchronized 关键字。

 

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

• 如果多线程访问同一个 Hashtable 就会直接造成锁冲突

• size 属性也是通过 synchronized 来控制同步,也是比较慢的。

• 一旦扩容,就由该线程完成整个扩容过程,这个过程会涉及到大量的元素拷贝,效率会非常低。

(2)ConcurrentHashMap 

相比于 Hashtable 做出了一系列的改进和优化,以java1.8为例 

1. 读操作没有加锁(但是使用了 volatile 保证从内存读取结果),只对写操作进行加锁,加锁的方式仍然是用 synchronized,但是不是锁整个对象,而是锁每个链表的头节点作为锁对象,大大降低了所冲突的概率(针对同一个哈希表的不同位置添加元素,不涉及到锁竞争)。

2. 充分利用了CAS特性,比如 size 属性通过CAS 来更新。避免出现重量级锁的情况

3. 优化了扩容方式:化整为零

• 发现需要扩容的线程,只需要创建一个新的数组,同时只搬几个元素过去。

• 扩容期间,新老数组同时存在

• 后续每个操作 ConcurrentHashMap 的线程,都会参与搬家的过程,每个操作负责搬运一小部分元素

• 搬完最后一个元素再把老数组删掉

• 这个期间,插入只往新数组加

• 这个期间,查找与删除需要同时查新数组和老数组

相关面试题:

(1)介绍下 ConcurrentHashMap 的锁分段技术

锁分段技术是 Java1.7中采取的技术。Java1.8 中已经不再使用了,简单的说就是把若干个哈希桶分成一个“段”针对每个段分别加锁。

目的也是为了降低锁竞争的概率,当两个线程访问的数据恰好在同一个段上,才触发锁竞争。 

 (2)ConcurrentHashMap 在jdk 1.8做了哪些优化操作?

取消了分段锁,直接给每个哈希桶(每个链表)分配了一个锁(就是给每个链表的头节点作为锁对象)。

将原来数组+链表 的实现方式改进成数组+链表/红黑树 的方式,当链表较长的时候(大于等于8个元素)就转换成红黑树。

(3)Hashtable 和 HashMap ,ConcurrentHashMap 之间的区别?

HashMap: 线程不安全,key 允许为null

Hashtable: 线程安全,使用 synchronized 锁Hashtable 对象,效率较低,key 不允许为null

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

七. 其他常见问题 

1. 谈谈 volatile 关键字的用法

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

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

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

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

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

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

• 通过 ThreadPoolExecutor 创建。创建的方式比较复杂,但是定制能力强。

LindedBlockingQueue 标志线程池的任务队列。用户通过 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 异常。  

你可能感兴趣的:(机器学习,人工智能)