多线程--进阶

简介: 多线程--进阶

一. 常见的锁策略

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

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

注意:

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

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

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

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

(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<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;
            }
        };
        // 把callable 实例使用FutureTask包装一下,不能直接传callable 
        FutureTask<Integer> 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 异常。  

相关文章
|
8月前
|
Linux API C++
|
8月前
|
关系型数据库 MySQL 编译器
C++进阶 多线程相关(下)
C++进阶 多线程相关(下)
41 0
|
8月前
|
安全
多线程【进阶版】(中)
多线程【进阶版】
35 0
|
8月前
|
安全 Java 调度
多线程【进阶版】(下)
多线程【进阶版】
43 0
|
8月前
多线程【进阶版】(上)
多线程【进阶版】
51 0
|
1月前
|
安全 算法 Java
多线程知识点总结
多线程知识点总结
42 3
|
8月前
|
算法 Ubuntu C++
[总结] C++ 知识点 《四》多线程相关
[总结] C++ 知识点 《四》多线程相关
|
10月前
|
存储 安全 Java
3.多线程(进阶)(二)
3.多线程(进阶)(二)
34 0
|
10月前
|
安全 Java Linux
3.多线程(进阶)(一)
3.多线程(进阶)
39 0
|
10月前
|
存储 安全 算法
3.多线程(进阶)(三)
3.多线程(进阶)(三)
36 0