Bootstrap

【收藏系列】多线程八股文总结

目录

1、乐观锁 VS 悲观锁

乐观锁与悲观锁的实现方式(含实例)

乐观锁的实现方式主要有两种:CAS机制和版本号机制

乐观锁和悲观锁优缺点和适用场景

乐观锁加锁吗?

CAS有哪些缺点?

2、普通互斥锁 VS 读写锁

3、轻量级锁 VS 重量级锁

4、自旋锁 VS 挂起等待锁

5、公平锁 VS 非公平锁

6、可重入锁 VS 不可重入锁

 7、Synchronized 原理

 7.1、锁升级(锁膨胀)

(1、偏向锁——无竞争

(2、轻量级锁——有竞争

(3、重量级锁——竞争激烈

7.2、锁消除

7.3、锁粗化

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

 8.1、Callable 接口

8.2、ReentrantLock

8.3、原子类

8.4、线程池

8.5、信号量Semaphore

8.6、CountDownLatch

 9、线程安全的集合类

9.1、多线程环境使用ArrayList

9.2、多线程环境使用队列 

9.3、多线程环境使用哈希表

10、死锁

10.1、什么是死锁

10.2、如何避免死锁

10.3、解决哲学家就餐问题

11、 Java多线程是如何实现数据共享的?


1、乐观锁 VS 悲观锁

        乐观锁、悲观锁顾名思义就是,形容心态,乐观把事情往好了想,悲观把事情往坏了想

乐观锁概念

        乐观锁在操作数据的时候,非常乐观,都认为别人不会同时修改数据,所以乐观锁在更新数据之前,都不会对数据进行加锁,只有当执行更新数据操作时候,再去判断数据是否被修改,如果数据被修改了,就放弃被当前修改操作。

悲观锁概念

        悲观锁在操作数据时候,比较悲观,都认为别人会和自己同时修改数据,所以悲观锁操作数据时候,会直接给数据上锁,不让别人操作,只有自己操作完成后,才释放锁。


乐观锁与悲观锁的实现方式(含实例)

        在说明实现方式之前,需要明确:乐观锁和悲观锁是两种思想,它们的使用是非常广泛的,不局限于某种编程语言或数据库。

        悲观锁的实现方式是加锁,加锁既可以是对代码块加锁(如synchronized关键字),也可以是对数据加锁(如MySQL中的排它锁)。

乐观锁的实现方式主要有两种:CAS机制和版本号机制

(1)CAS(Compare And Swap)

CAS操作包括了3个操作数:

 1) 需要读写的内存位置(V) - 内存值
 2) 进行比较的预期值(A) - 旧预期值
 3) 拟写入的新值(B) - 新值
if(V = A){
    V = B
}

        因此,如果并发执行自增操作,可能导致计算结果的不准确。

        在下面的代码示例中:value1没有进行任何线程安全方面的保护,value2使用了乐观锁(CAS),value3使用了悲观锁(synchronized)。

        运行程序,使用1000个线程同时对value1、value2和value3进行自增操作,可以发现:value2和value3的值总是等于1000,而value1的值常常小于1000。


public class Test {

        //value1:线程不安全  
        private static int value1 = 0;
        //value2:使用乐观锁  
        private static AtomicInteger value2 = new AtomicInteger(0);
        //value3:使用悲观锁  
        private static int value3 = 0;

        private static synchronized void increaseValue3() {
            value3++;
        }

        public static void main(String[] args) throws Exception {
            //开启1000个线程,并执行自增操作  
            for (int i = 0; i < 1000; ++i) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            Thread.sleep(100);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        value1++;
                        value2.getAndIncrement();
                        increaseValue3();
                    }
                }).start();
            }
            //查看活跃线程 
            while (Thread.activeCount() > 2) {
                //Thread.currentThread().getThreadGroup().list();
                Thread.yield();//让出cpu
            }

            //打印结果  
            Thread.sleep(1000);
            System.out.println("线程不安全:" + value1);
            System.out.println("乐观锁(AtomicInteger):" + value2);
            System.out.println("悲观锁(synchronized):" + value3);
        }
}

1)首先来介绍AtomicInteger

AtomicInteger是java.util.concurrent.atomic包提供的原子类利用CPU提供的CAS操作来保证原子性;

除了AtomicInteger外,还有AtomicBoolean、AtomicLong、AtomicReference等众多原子类。

源码分析说明如下:

  • getAndIncrement()实现的自增操作是自旋CAS操作:在循环中进行compareAndSet,如果执行成功则退出,否则一直执行。
  • 其中compareAndSet是CAS操作的核心,它是利用Unsafe对象实现的。
  • Unsafe又是何许人也呢?

        Unsafe是用来帮助Java访问操作系统底层资源的类(如可以分配内存、释放内存,在netty中大量用到它,属于C++层面的native方法,我们一般使用反射获取),通过Unsafe,Java具有了底层操作能力,可以提升运行效率;

        强大的底层资源操作能力也带来了安全隐患(类的名字Unsafe也在提醒我们这一点),因此正常情况下用户无法使用。

AtomicInteger在这里使用了Unsafe提供的CAS功能。

  • valueOffset可以理解为value在内存中的偏移量,对应了CAS三个操作数(V/A/B)中的V;偏移量的获得也是通过Unsafe实现的。
  • value域的volatile修饰符:Java并发编程要保证线程安全,需要保证原子性、可视性和有序性;

CAS操作可以保证原子性,而volatile可以保证可视性和一定程度的有序性;

在AtomicInteger中,volatile和CAS一起保证了线程安全性。

2) 说完了AtomicInteger,再说synchronized

        synchronized通过对代码块加锁来保证线程安全:在同一时刻,只能有一个线程可以执行代码块中的代码。

        synchronized是一个重量级的操作,不仅是因为加锁需要消耗额外的资源,还因为线程状态的切换会涉及操作系统核心态和用户态的转换;

        不过随着JVM对锁进行的一系列优化(如自旋锁、轻量级锁、锁粗化等),synchronized的性能表现已经越来越好。

(2)版本号机制实现乐观锁

        版本号机制的基本思路是在数据中增加一个字段version,表示该数据的版本号,每当数据被修改,版本号加1。

  • 当某个线程查询数据时,将该数据的版本号一起查出来;
  • 当该线程更新数据时,判断当前版本号与之前读取的版本号是否一致,如果一致才进行操作。

        需要注意的是,这里使用了版本号作为判断数据变化的标记,实际上可以根据实际情况选用其他能够标记数据版本的字段,如时间戳等。


乐观锁和悲观锁优缺点和适用场景

乐观锁和悲观锁并没有优劣之分,它们有各自适合的场景;下面从两个方面进行说明。

  • 功能限制 与悲观锁相比,乐观锁适用的场景受到了更多的限制,无论是CAS还是版本号机制。

        例如,CAS只能保证单个变量操作的原子性,当涉及到多个变量时,CAS是无能为力的,而synchronized则可以通过对整个代码块加锁来处理。

        再比如版本号机制,如果query的时候是针对表1,而update的时候是针对表2,也很难通过简单的版本号来实现乐观锁。

  • 竞争激烈程度 如果悲观锁和乐观锁都可以使用,那么选择就要考虑竞争的激烈程度:

        当竞争不激烈 (出现并发冲突的概率小)时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。

        当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。

乐观锁加锁吗?

下面是我对这个问题的理解:

  1. 乐观锁本身是不加锁的,只是在更新时判断一下数据是否被其他线程更新了;AtomicInteger便是一个例子。
  2. 有时乐观锁可能与加锁操作合作,例如,在前述updateCoins()的例子中,MySQL在执行update时会加排它锁。

但这只是乐观锁与加锁操作合作的例子,不能改变“乐观锁本身不加锁”这一事实。


CAS有哪些缺点?

面试到这里,面试官可能已经中意你了。

不过面试官准备对你发起最后的进攻:你知道CAS这种实现方式有什么缺点吗?

下面是CAS一些不那么完美的地方:

1.ABA问题 假设有两个线程——线程1和线程2,两个线程按照顺序进行以下操作:

  • (1)线程1读取内存中数据为A;
  • (2)线程2将该数据修改为B;
  • (3)线程2将该数据修改为A;
  • (4)线程1对数据进行CAS操作

        在第(4)步中,由于内存中数据仍然为A,因此CAS操作成功,但实际上该数据已经被线程2修改过了。这就是ABA问题。

在AtomicInteger的例子中,ABA似乎没有什么危害。

        但是在某些场景下,ABA却会带来隐患,例如栈顶问题:一个栈的栈顶经过两次(或多次)变化又恢复了原值,但是栈可能已发生了变化。

        对于ABA问题,比较有效的方案是引入版本号,内存中的值每发生一次变化,版本号都+1;

        在进行CAS操作时,不仅比较内存中的值,也会比较版本号,只有当二者都没有变化时,CAS才能执行成功。

Java中的AtomicStampedReference类便是使用版本号来解决ABA问题的。

2.高竞争下的开销问题 在并发冲突概率大的高竞争环境下,如果CAS一直失败,会一直重试,CPU开销较大。

针对这个问题的一个思路是引入退出机制,如重试次数超过一定阈值后失败退出。

当然,更重要的是避免在高竞争环境下使用乐观锁

3.功能限制 CAS的功能是比较受限的,例如CAS只能保证单个变量(或者说单个内存值)操作的原子性,这意味着:

  • 原子性不一定能保证线程安全,例如在Java中需要与volatile配合来保证线程安全;
  • 当涉及到多个变量(内存值)时,CAS也无能为力。

        除此之外,CAS的实现需要硬件层面处理器的支持,在Java中普通用户无法直接使用,只能借助atomic包下的原子类使用,灵活性受到限制。


2、普通互斥锁 VS 读写锁

互斥锁         

        互斥锁是一种简单的加锁的方法来控制对共享资源的访问,互斥锁只有两种状态,即上锁( lock )和解锁(unlock),如果互斥量已经上锁,调用线程会阻塞,直到互斥量被解锁。在完成了对共享资源的访问后,要对互斥量进行解锁

         互斥锁特点

  1. 原子性:把一个互斥量锁定为一个原子操作,操作系统保证了如果一个线程锁定了一个互斥量,没有其他线程在同一时间可以成功锁定这个互斥量;
  2. 唯一性:如果一个线程锁定了一个互斥量,在它解除锁定之前,没有其他线程可以锁定这个互斥量;
  3. 非繁忙等待:如果一个线程已经锁定了一个互斥量,第二个线程又试图去锁定这个互斥量,则第二个线程将被挂起(不占用任何cpu资源),直到第一个线程解除对这个互斥量的锁定为止,第二个线程则被唤醒并继续执行,同时锁定这个互斥量

 读写锁

        读写锁允许更改的并行性,写的串行性,也叫共享互斥锁。 互斥量要么是锁住状态,要么就是不加锁状态,而且一次只有一个线程可以对其加锁。 读写锁可以有3种状态:读模式下加锁状态、写模式加锁状态、不加锁状态。 一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁(允许多个线程读但只允许一个线程写

        读写锁特点

  1. 如果有其它线程读数据,则允许其它线程执行读操作,但不允许写操作;
  2. 如果有其它线程写数据,则其它线程都不允许读、写操作


3、轻量级锁 VS 重量级锁

 轻量级锁

        轻量级锁:是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁

         重量级锁: 是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低


4、自旋锁 VS 挂起等待锁

自旋锁

        自旋锁与互斥量功能一样,唯一一点不同的就是互斥量阻塞后休眠让出cpu,而自旋锁阻塞后不会让出cpu,会一直忙等待,直到得到锁,原地打转 自旋锁在用户态使用的比较少,在内核使用的比较多!自旋锁的使用场景:锁的持有时间比较短,或者说小于2次上下文切换的时间。

 自旋锁特点

  1.  某个协程持有锁时间长,等待的协程一直在循环等待,消耗CPU资源。
  2.  不公平,有可能存在有的协程等待时间过程,出现线程饥饿(这里就是协程饥饿)

挂起等待锁

         如果线程获取不到锁就会堵塞等待,啥时候结束等待就要看cpu的调度策略,在挂起的时候是不吃cpu的资源的


5、公平锁 VS 非公平锁

公平锁

  • 公平锁是指多个线程按照申请锁的顺序来获取锁。

非公平锁

  • 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。

        对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。 对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。

        打一个不恰当的例子,公共厕所男女一起排队,当厕所里面是将男女分开的,这时当里面一个男的出来了,而外面排队的前三个人都是女的,队伍中就会直接让第四个人进去,此时的优先级就是男>女的,也就是非公平锁了

注:

  • 操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
  • 公平锁和非公平锁没有好坏之分, 关键还是看适用场景.

6、可重入锁 VS 不可重入锁

可重入锁

        为了解决互斥锁导致的死锁问题(哲学家吃面问题),引入可重入锁又叫递归锁
        可重入内部维护着一个锁和一个计数器,计数器记录了获取锁的次数,从而使得资源可以被同一个线程多次获取,直到一个线程所有的获取都被释放,其他的线程才能获得资源

 不可重入锁

 不可重入锁:与可重入锁相反,不可递归调用,递归调用就发生死锁。

以上六组就是我们常见的锁策略 


 7、Synchronized 原理

 7.1、锁升级(锁膨胀)

Synchronized 加锁的具体过程:

(1、偏向锁——无竞争

        偏向锁并不是真的“加锁”,而是给对象头中做一个“偏向锁的标记”,记录这个锁属于哪个线程

        如果后续没有其他线程来竞争这个锁,那就不用加锁了,可以避免加锁解锁带来的开销

        但是如果后续有其他线程来竞争该锁,(因为它已经做了标记,很容易识别 它是否是刚才记录的线程),就会取消偏向锁状态,进入一般的轻量级锁状态

        怎么去理解呢?其实就是危机感的问题,比如,那么男女交往过程中,双方确定关系,只是彼此知道而已,但别人并不知道,他们也没有官宣(官宣---加锁,官宣分手---需要开销即需要时间),此时就相当于偏向锁,而此时,突然出现了一个男配或者女配的时候,他们产生了危机感,就会立即官宣(加锁)。再打个比方,同志A去上厕所,他没有习惯关门(关门---加锁,开门---解锁,卡开关门的操作需要ATP,消耗能量),每次听到外面有脚步声来了,才会赶快去关上门。

(2、轻量级锁——有竞争

轻量级锁状态 ( 自适应的自旋锁 ).
此处的轻量级锁就是通过 CAS 来实现
  • 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)
  • 如果更新成功, 则认为加锁成功
  • 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).
自旋操作是一直让 CPU 空转 , 比较浪费 CPU 资源 .
因此此处的自旋不会一直持续进行 , 而是达到一定的时间 / 重试次数 , 就不再自旋了 .
也就是所谓的 " 自适应 "

(3、重量级锁——竞争激烈

如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁
此处的重量级锁就是指用到内核提供的 mutex .
  • 执行加锁操作, 先进入内核态.
  • 在内核态判定当前锁是否已经被占用
  • 如果该锁没有占用, 则加锁成功, 并切换回用户态.
  • 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
  • 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁.

7.2、锁消除

锁消除其实就是JVM做出的一个优化,JVM自动判定这个地方是不需要锁的,如果你加了Synchronized,就会自动的把锁去掉

这里JVM是在有百分之百的把握下才会进行锁消除


7.3、锁粗化

就是把细粒度的加锁优化为粗粒度的加锁,如图:


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

这个包中放的都是与多线程相关的


 8.1、Callable 接口

 Callable 接口创建线程:http://t.csdn.cn/skpC0

理解 Callable
Callable Runnable 相对 , 都是描述一个 " 任务 ". Callable 描述的是带有返回值的任务 ,
Runnable 描述的是不带返回值的任务 .
Callable 通常需要搭配 FutureTask 来使用 . FutureTask 用来保存 Callable 的返回结果 . 因为
Callable 往往是在另一个线程中执行的 , 啥时候执行完并不确定 .
FutureTask 就可以负责这个等待结果出来的工作 .
理解 FutureTask
FutureTask 就可以负责这个等待结果出来的工作 .
我们平时在食堂吃饭,  当餐点好后 , 后厨就开始做了 . 同时前台会给你一张 " 小票 " . 这个小票就是
FutureTask. 后面我们可以随时凭这张小票去查看自己的饭做出来了没 .

8.2、ReentrantLock

ReentrantLock 的用法:

lock(): 加锁 , 如果获取不到锁就死等 .
trylock( 超时时间 ): 加锁 , 如果获取不到锁 , 等待一定的时间之后就放弃加锁 .
unlock(): 解锁

ReentrantLock 和 synchronized的区别:

  1. tryLock试试看能不能加上锁,成功就成功了,失败就放弃(可以指定加锁的等待超时时间)。synchronized申请失败会死等
  2. ReentrantLock可以实现公平锁,默认中是非公平的,构造的时候传入一个简单的参数(true)就成了公平锁
  3. synchronized是搭配wait/notify实现等待通知机制的,唤醒操作是随机唤醒一个
  4. ReentrantLock搭配Condition类实现的,唤醒操作是可以指定唤醒那个等待的线程
  5. synchronized使用使不需要手动释放锁,ReentrantLock使用时需要手动释放,使用起来更灵活,也更容易遗漏unlock
如何选择使用哪个锁?
  • 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
  • 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
  • 如果需要使用公平锁, 使用 ReentrantLock.

8.3、原子类

原子类的底层是基于CAS实现的,这里使用API文档查看即可

synchronized相比于CAS,虽然没有CAS好用,但是synchronized的打击面广,使用场景更多,更加通用


8.4、线程池

这篇博文已经详细总结了:http://t.csdn.cn/f1a9s

另外利用线程池创建线程:http://t.csdn.cn/skpC0


8.5、信号量Semaphore

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

信号量的两个基本操作:

  • P操作,申请一个资源,可用资源数就+1
  • V操作,释放一个资源,可用资源数-1
  • 当计数为0时,进行P操作,就会产生阻塞

例:

public class Test {
    public static void main(String[] args) throws InterruptedException {
        //指定计数器的初始值,表示有几个资源
        Semaphore semaphore = new Semaphore(4);
        //P-操作,申请资源,计数器-1
        semaphore.acquire();
        System.out.println("P操作");
        semaphore.acquire();
        System.out.println("P操作");
        semaphore.acquire();
        System.out.println("P操作");
        //V-操作,释放资源,计数器+1
        semaphore.release();
        System.out.println("V操作");
        semaphore.acquire();
        System.out.println("P操作");
        semaphore.acquire();
        System.out.println("P操作");
        semaphore.acquire();
        System.out.println("P操作");//这个不会输出,阻塞等待了
    }
}


8.6、CountDownLatch

CountDownLatch允许一个或者多个线程去等待其他线程完成操作。

例如,在跑步中,等待所有人都跑完了,才算比赛结束:

import java.util.concurrent.CountDownLatch;

public class Test {
    public static void main(String[] args) throws InterruptedException {
        // 有 10 个选手参加了比赛
        CountDownLatch countDownLatch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            // 创建 10 个线程来执行一批任务.
            Thread t = new Thread(() -> {
                System.out.println("选手出发! " + Thread.currentThread().getName());
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("选手到达! " + Thread.currentThread().getName());
                // 撞线
                countDownLatch.countDown();
            });
            t.start();
        }

        // await 会等到所有的选手都撞线
        countDownLatch.await();
        System.out.println("比赛结束!");
    }
}

CountDownLatch 提供的一些方法: 


 9、线程安全的集合类

9.1、多线程环境使用ArrayList

1)、自己使用同步机制 (synchronized 或者 ReentrantLock)

2)、Collections.synchronizedList(new ArrayList);

  •         synchronizedList是标准库提供的一个基于synchronized进行线程同步的List
  •         synchronizedList的关键操作上都带有synchronized

        这个方法固然是好的,但是更建议自己手动加锁,因为,有的地方可能就是不需要加锁,而这个方法是给所有都加锁了,难免会降低效率的 

3)、使用 CopyOnWriteArrayList

CopyOnWrite容器即写时复制的容器
        当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy
复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将 原容器的引用指向新的容
器。 (旧的容器会释放)
        这样做的好处是我们可以对CopyOnWrite 容器进行并发的读,而 不需要加锁 ,因为当前容器
不会添加任何元素。
        所以CopyOnWrite 容器也是一种 读写分离的思想 ,读和写不同的容器。
优点 :
  • 在读多写少的场景下,性能高,不需要加锁竞争
缺点:
  • 占用内存较多
  • 新写的数据不能第一时间读取到

9.2、多线程环境使用队列 

1) 、ArrayBlockingQueue
基于数组实现的阻塞队列
2)、LinkedBlockingQueue
基于链表实现的阻塞队列
3)、 PriorityBlockingQueue
基于堆实现的带优先级的阻塞队列
4)、 TransferQueue
最多只包含一个元素的阻塞队列
关于阻塞队列案例实现,这篇博客中有:

9.3、多线程环境使用哈希表

HashMap是线程不安全的

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

  1. HashTable【不建议使用】
  2. ConcurrentHashMap【推荐使用,重点掌握】
1)、 Hashtable

 这里是无脑给各种方法加synchronized

这相当于直接针对 Hashtable 对象本身加锁 .
  1. 如果多线程访问同一个 Hashtable 就会直接造成锁冲突.
  2. size 属性也是通过 synchronized 来控制同步, 也是比较慢的.
  3. 一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低.
2)、 ConcurrentHashMap

 ConcurrentHashMap核心优化思路,尽可能降低锁冲突的概率

  •         ConcurrentHashMap是给每个哈希桶一把锁,只有当两个线程访问同一个哈希桶上的数据才会出现锁冲突(算是对Hashtable 做出的一个优化)【控制锁的粒度

注:产生锁冲突,对于性能影响是非常大的

  •         ConcurrentHashMap只是给写操作加锁,读操作不加锁,当两个线程同时修改时,才会产生锁冲突
  •         充分利用CAS特性

例如:维护元素个数,都是通过CAS实现,而不是加锁

  • ConcurrentHashMap对于扩容操作,进行了化整为零的特殊优化

HashTable扩容:

        当添加元素时,发现负载因子已经超过阈值时,触发扩容机制,申请一个更大的数组,将原来的数据搬运至新的数组上

问题:

        当需要搬运的元素特别多的时候,该操作的开销会非常大,就可能会导致添加某个元素时,卡了很久才添加上,严重的甚至导致请求添加失败

ConcurrentHashMap扩容优化:

        扩容时,不是一次性将所有数据全部搬运过去,而是依次搬运一点,就像愚公移山一样

        并且,在扩容过程中,旧的数据和新的数据会同时存在一段时间,直到搬运完成,才会释放旧的数据的空间

        在这个过程中,如果要进行查询元素,就会新、旧数据一起查询,插入数据,直接插入,删除数据,如果该数据还在旧的数据空间中,就不用搬运该数据了

补充两点:

  • 分段锁

ConcurrentHashMap中旧版本的实现方式,好几个链表分一个锁,锁冲突概率更高,Java8开始就不是这样了,而是一个链表分一个锁

  • HashMap key可以为null,HashTable,ConcurrentHashMap key不能为null

10、死锁

10.1、什么是死锁

场景举例:

例1: 一个线程一把锁

         当一个线程一把锁,连续加锁两次,导致死锁,但是如果该锁是可重入锁,就不会出现死锁情况(synchronized就是可重入锁)

例2:两个线程两把锁

        例如有两个线程:

        线程A需要先锁a,再锁b,而线程B需要先锁b再锁a,那么就会出现,A把a锁了,B把b锁了,两个互不相让 ,僵持在这里,这时就构成了一个死锁的状态

        换句话来说,就是车钥匙锁在家里,而家钥匙锁在车里了,就是一个死锁的状态

看代码:

        死锁情况:将上述两个synchronized进行嵌套,也就是说,第一把锁还没有释放,就得给第二个加锁

public class Test {
    public static void main(String[] args) {
        Object locker1 = new Object();
        Object locker2 = new Object();

        Thread t1 = new Thread(() -> {
            System.out.println("t1尝试获取locker1");
            synchronized (locker1) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                System.out.println("t1尝试获取locker2");
                synchronized (locker2) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    System.out.println("t1获取两把锁成功");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            System.out.println("t2尝试获取locker2");
            synchronized (locker2) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                System.out.println("t2尝试获取locker1");
                synchronized (locker1) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                    System.out.println("t1获取两把锁成功");
                }
            }
        });
        t1.start();
        t2.start();
    }
}

例3:多个线程多把锁 

        一个经典的问题就是哲学家就餐问题,该问题是描述有五个哲学家共用一张圆桌,分别坐在周围的五张椅子上,在圆桌上有五个碗和五只筷子,他们的生活方式是交替地进行思考和进餐。平时,一个哲学家进行思考,饥饿时便试图取用其左右最靠近他的筷子,只有在他拿到两只筷子时才能进餐。进餐完毕,放下筷子继续思考。

五个哲学,都先拿起自己左手边的筷子,导致,都吃不了饭,僵持不下,死锁


10.2、如何避免死锁

死锁产生的四个必要条件:
  • 互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用(锁的基本特性,无法打破)
  • 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。(锁的基本特性,无法打破)
  • 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。(取决代码实现,及需求场景)
  • 循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。
        当上述四个条件都成立的时候,便形成死锁。当然,死锁的情况下如果打破上述任何一个条件,便可让 死锁消失。
其中最容易破坏的就是 "循环等待".
破坏循环等待:
        最常用的一种死锁阻止技术就是锁排序 . 假设有 N 个线程尝试获取 M 把锁 , 就可以针对 M 把锁进行编号 (1, 2, 3...M).
        N 个线程尝试获取锁的时候 , 都按照固定的编号由小到大顺序来获取锁 . 这样就可以避免环路等待 .(推荐使用)

10.3、解决哲学家就餐问题

三个方案
  1.  所有哲学家先拿起编号小的筷子,1号哲学家就会和2号哲学家竞争0号筷子,从这个入口,就可以解决问题了
  2.  仅当哲学家的左、右两只筷子均可用时,才允许他拿起筷子进餐。
  3.  规定奇数号哲学家先拿他左边的筷子,然后再去拿右边的筷子,而偶数号哲学家则相反。按此规定,将是 1、2 号哲学家竞争 1 号筷子;3、4 号哲学家竞争 3 号筷子。即五位哲学家都先竞争奇数号筷子,获得后,再去竞争偶数号筷子,最后总会有一位哲学家能获得两只筷子而进餐。
     

11、 Java多线程是如何实现数据共享的?

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

下期见!!! 

;