JavaEE 初阶 -- 多线程进阶

文章目录

  • 常见锁策略
    • 乐观锁 VS 悲观锁
    • 轻量级锁 VS 重量级锁
    • 自旋锁 VS 挂起等待锁
    • 互斥锁 VS 读写锁
    • 可重入锁 VS 不可重入锁
      • 关于死锁的情况
        • 死锁的4个必要条件(缺一不可)
    • 公平锁和非公平锁
      • synchronized的特点
    • 关于锁策略的几个面试题
      • 1. 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?
      • 2. 介绍下读写锁?
      • 3. 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?
      • 4. synchronized 是可重入锁么?
  • CAS
    • 什么是CAS
    • 基于CAS实现的操作
      • 1. 实现原子类
      • 实现自旋锁
      • 面试题:CAS中的aba问题
        • 如何解决aba问题
  • synchronized原理
    • synchronized关键策略:锁升级
    • 锁消除
    • 锁粗化
  • Java中的JUC(java.util.concurrent)
    • Callable
    • ReentrantLock
      • ReentrantLock 和 synchronized 区别
    • 信号量 - Semaphore
    • CountDownLatch
    • 相关面试题
      • 线程同步的方式有哪些?
      • 为什么有了 synchronized 还需要 juc 下的 lock ?
      • 信号量听说过么?之前都用在过哪些场景下?
  • 线程安全的集合类
    • 多线程环境使用 ArrayList
      • 自己使用同步机制 (synchronized 或者 ReentrantLock)
      • 使用标准库里面提供的一个 套壳操作: Collections.synchronizedList(new ArrayList);
      • 使用 CopyOnWriteArrayList
    • 多线程环境使用队列
    • 多线程环境使用哈希表
      • HashTable 和 ConcurrentHashMap 的区别
        • 加锁粒度不同
        • 其它方面的改进
      • 相关面试题

常见锁策略

乐观锁 VS 悲观锁

锁的实现者,预测接下来锁冲突(就是锁竞争,两个线程针对一个对象加锁,产生阻塞等待了)的概率是大,还是不大,来决定接下来咋做。

乐观锁:预测接下来冲突概率不大
悲观锁:预测接下来冲突概率比较大
通常来说,悲观锁一般要做的工作更多一些,效率会更低一些,乐观锁做的工作会更少一点,效率更高一点(但是并不绝对)

轻量级锁 VS 重量级锁

轻量级锁,加锁解锁,过程更快更高效
重量级锁,加锁解锁,过程更慢更低效

一个乐观锁可能也是一个轻量级锁,一个悲观锁可能也是一个重量级锁(都不绝对)。
他们和乐观悲观锁,虽然不是一回事,但确实有一定的重合。

自旋锁 VS 挂起等待锁

自旋锁是轻量级锁的一种典型实现
挂起等待锁是重量级锁的一种典型实现

自旋锁通常是纯用户态的,不需要经过内核态(时间相对更短)
挂起等待锁通过内核的机制来实现挂起等待(时间更长了)

自旋锁,一旦锁被释放,就能第一时间拿到锁,速度会更快,不过会忙等,消耗CPU资源。
挂起等待锁,如果锁被释放,不能第一时间拿到锁,可能需要过很长时间才能拿到锁,但是这个空出来的时间,是可以干别的事情的。

  • 针对上面3种策略,synchronized这把锁属于哪种?
    JavaEE 初阶 -- 多线程进阶_第1张图片

互斥锁 VS 读写锁

synchronized,是互斥锁,加锁,就只是单纯的加锁,没有更细化的区分了,像它这样的锁只有两个操作:1. 进入代码块,加锁 2. 出了代码块,解锁

除了这种之外,还有一种读写锁,能够把读和写,两种加锁区分开
读写锁:1. 给读加锁 2. 给写加锁 3.解锁

读写锁中约定:

  1. 读锁和读锁之间,不会锁竞争,不会产生阻塞等待,就不会影响程序的速度,代码还是跑的很快
  2. 写锁和写锁之间,有锁竞争
  3. 写锁和读锁之间,也有锁竞争
    2和3减慢了速度,但是保证准确性,所以读写锁更加适合于,一些多读的情况

标准库中提供了另外两个专门的读写锁,读锁是一个类,写锁是一个类
ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.

可重入锁 VS 不可重入锁

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

JavaEE 初阶 -- 多线程进阶_第2张图片
可是实际上,在java中并不会死锁,因为synchronized是一个可重入锁,在加锁的时候,会判定一下,看当前尝试申请锁的线程是不是已经就是锁的拥有者了,如果是,就直接放行!

关于死锁的情况

  1. 一个线程,一把锁,就是上面这种情况,可重入锁没事,不可重入锁死锁
  2. 两个线程,两把锁,即使是可重入锁,也会死锁
  3. N 个线程,M 把锁

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

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

死锁的4个必要条件(缺一不可)
  1. 互斥使用:一个线程拿到一把锁之后,另外一个线程不能使用
  2. 不可抢占:一个线程拿到锁,只能自己主动释放,不能被其它线程强行占有
  3. 请求和保持:当一个线程占据了多把锁之后,除非显式的释放锁,否则这些锁始终都是被该线程持有的(”吃着碗里的,惦记着锅里的“)
  4. 循环等待:等待形成了一个环(哲学家吃面)

公平锁和非公平锁

约定,遵守先来后到的是公平锁,不遵守的是非公平锁。
操作系统对于线程的调度是随机的。自带的synchronized这个锁,是非公平的,想要实现非公平锁,需要在synchronized的基础上,加上一个队列来记录这些加锁线程的顺序。

学到这里,我们来总结一下synchronized:

synchronized的特点

  1. 既是乐观锁,也是悲观锁
  2. 既是轻量级锁,也是重量级锁
  3. 轻量级锁基于自旋实现,重量级锁基于挂起等待实现
  4. 不是读写锁,是互斥锁
  5. 是可重入锁
  6. 是非公平锁

关于锁策略的几个面试题

1. 你是怎么理解乐观锁和悲观锁的,具体怎么实现呢?

悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁.
乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突.
悲观锁的实现就是先加锁(比如借助操作系统提供的 mutex), 获取到锁再操作数据. 获取不到锁就等待.
乐观锁的实现可以引入一个版本号. 借助版本号识别出当前的数据访问是否冲突.

2. 介绍下读写锁?

读写锁就是把读操作和写操作分别进行加锁.
读锁和读锁之间不互斥.
写锁和写锁之间互斥.
写锁和读锁之间互斥.
读写锁最主要用在 “频繁读, 不频繁写” 的场景中

3. 什么是自旋锁,为什么要使用自旋锁策略呢,缺点是什么?

如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来. 一旦锁被其他线程释放, 就能第一时间获取到锁.
相比于挂起等待锁:
优点: 没有放弃 CPU 资源, 一旦锁被释放就能第一时间获取到锁, 更高效. 在锁持有时间比较短的场景下非常有用.
缺点: 如果锁的持有时间较长, 就会浪费 CPU 资源

4. synchronized 是可重入锁么?

是可重入锁.
可重入锁指的就是连续两次加锁不会导致死锁.
实现的方式是在锁中记录该锁持有的线程身份, 以及一个计数器(记录加锁次数). 如果发现当前加锁的线程就是持有锁的线程, 则直接计数自增

CAS

什么是CAS

CAS :compare and swap
它做的事情:拿着寄存器 A 的值和内存 M 的值进行比对,如果值相同,就把寄存器 B 的值和 M 的值进行交换。
这个寄存器 B,大概率也是来源于另一个内存,而且更多时候,不关心寄存器中的值,更关心内存是数值(就是变量的值)。

JavaEE 初阶 -- 多线程进阶_第5张图片

基于CAS实现的操作

1. 实现原子类

标准库里提供了一组原子类。
针对锁常用的一些,int,long,数组等等进行了封装(比如对于整形的就是AtomInteger),可以基于CAS的方法进行修改,并且线程安全。


这些操作,要比之前加锁操作,执行的更快,synchronized会涉及到锁的竞争,两个线程要相互等待,有了等待过程,代码执行的速度就会慢下来,但是CAS实现的 ++ 并不会涉及到线程的阻塞等待,也就是说,两个线程同时进行非常的快!

实现自旋锁

JavaEE 初阶 -- 多线程进阶_第6张图片

  • 有人问,CAS保证内存可见性吗?

不能!只能保证原子性,无法保证内存可见性,而且对于原子类 AtomicInteger 源码中的 value 也是加了 volatile 修饰的。举个例子:假设有两个线程 T1 和 T2 同时对变量 X 进行修改。初始状态下,变量 X 的值为0。T1将 X 的值增加了1,T2将 X 的值增加了2。如果 T1 和 T2 都使用了 CAS 操作,则最终的变量 X 的值应该为3。然而,在多核处理器的架构下,由于每个线程都有自己的高速缓存,当 T1 和 T2 分别对 X 进行 CAS 操作时,它们可能只会将自己的缓存中的 X 的值更新一次,而不会直接写回到主存中。具体来说,如果 T1 和 T2 在不同的核心上执行,那么它们寄存器中的 X 值会被写入各自核心的高速缓存中,并不会立即写回到主存。因此,当 T2 要修改 X 的时候,它并不会获取到 T1 对 X 的修改,而只会获取到主存中的初始值0。此时,T2 对 X 的修改会覆盖掉 T1 的修改,将 X 的值设置为2。最终的结果是,变量 X 的值会变成2,而不是3。

面试题:CAS中的aba问题

CAS只能对比值是否相同,不能确定这个值中间是否变过

大部分情况下是不会有bug的,小概率情况下就不一定了,就像你买了张矿卡一样,平时使用是没问题的,但是不能保证用多了就不会有问题。

JavaEE 初阶 -- 多线程进阶_第7张图片

如何解决aba问题

JavaEE 初阶 -- 多线程进阶_第8张图片
CAS解释了自旋锁的实现

synchronized原理

synchronized工作过程中 ,都干了什么?

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

synchronized关键策略:锁升级

JavaEE 初阶 -- 多线程进阶_第9张图片
JavaEE 初阶 -- 多线程进阶_第10张图片

锁消除

JavaEE 初阶 -- 多线程进阶_第11张图片
像我们之前学过的StringBuffer是把关键的方法,都加上了synchronized,如果是单线程使用StringBuffer,不涉及线程安全问题,相等于加锁操作没有真正被编译。

锁粗化

锁的粗细是指 “锁的粒度” ,粒度就是加锁代码设计的范围,加锁代码的范围越大,锁的粒度就越粗,加锁的代码范围越小,粒度就越细。一搬写代码的时候,多数情况下,是希望锁的粒度更小一点(串行执行的代码少,并行的代码就多),但是如果某个场景,要频繁加锁解锁,此时编译器就可能把这个操作优化成一个更粗粒度的锁,因为每次锁竞争都可能引入一定的等待开销,此时整体的效率可能反而更低。

JavaEE 初阶 -- 多线程进阶_第12张图片

Java中的JUC(java.util.concurrent)

Callable

非常类似于Runnable,Runnable描述了一个任务,一个线程要干嘛。Runnable通过run方法描述,返回类型void,而Callable通过call方法,有返回值

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

public class ThreadDemo31 {
    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 = 1; i <= 1000; i++) {
                    sum += i;
                }
                return sum;
            }
        };

        // 还需要找个人来完成这个任务(线程)
        // Thread 不能直接传 callable,需要再包装一层
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread t = new Thread(futureTask);
        t.start();

        System.out.println(futureTask.get());
    }
}

  • 所以,记住,创建线程又多了一种方法:实现Callable

ReentrantLock

JavaEE 初阶 -- 多线程进阶_第13张图片

ReentrantLock 和 synchronized 区别

相同点:
synchronized 和 ReentrantLock 都是 Java 中提供的可重入锁
不同点:
用法不同:synchronized 可以用来修饰普通方法、静态方法和代码块;ReentrantLock 只能用于代码块;
获取和释放锁的机制不同:进入synchronized 块自动加锁和执行完后自动释放锁; ReentrantLock 需要显示的手动加锁和释放锁;
锁类型不同:synchronized 是非公平锁; ReentrantLock 默认为非公平锁,也可以手动指定为公平锁;
响应中断不同:synchronized 不能响应中断;ReentrantLock 可以响应中断,可用于解决死锁的问题;
底层实现不同:synchronized 是 JVM 层面通过监视器实现的;ReentrantLock 是基于 AQS 实现的。
JavaEE 初阶 -- 多线程进阶_第14张图片
这里插一条相关专业知识:
JavaEE 初阶 -- 多线程进阶_第15张图片
不过,这只是一条规矩,其实用哪个可以根据你的客观要求和主观爱好来决定。

信号量 - Semaphore

所谓的锁,本质上是计数器为 1 的信号量,取值只有 1 和 0 两种,也叫二元信号量。Semaphore是一个更广义的锁,不光能管理非 0 即 1 的资源,也能管理多个资源。

举个例子:你开车进入停车场,每次进入停车场,有个牌子计数里面的车位,每进去一辆车,车位就减一,每次出来一辆车,车位就加一,当车位为 0 的时候,外面想要进去的车就会阻塞等待,等到有车出来了才能进去。

P操作:申请资源,计数器-1,accquire方法
V操作:释放资源,计数器+1,release方法
二元信号量:
P == 加锁
V == 解锁

CountDownLatch

50m赛跑,比赛啥时候开始,枪响了就开始,什么时候结束,等最后一个人到达终点就结束
这个类就可以感知到最后一个选手是啥时候到终点的

举个例子:

你要下载一个很大的文件,这时候如果只是一个线程下载的话,就会很慢,所以会多线程下载:
JavaEE 初阶 -- 多线程进阶_第16张图片

import java.util.concurrent.CountDownLatch;
public class Test5 {
    public static void main(String[] args) {
        CountDownLatch latch = new CountDownLatch(10);// 10个选手
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(()->{
                try {
                    Thread.sleep(3000);
                    System.out.println(Thread.currentThread().getName() + "达到终点");
                    latch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            t.start();
        }
        // 当这些线程没有执行完的时候,await 就阻塞等待
        // 所有的线程都执行完了,await 才返回
        try {
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("比赛结束");
    }
}

相关面试题

  1. 线程同步的方式有哪些?

synchronized, ReentrantLock, Semaphore 等都可以用于线程同步.

  1. 为什么有了 synchronized 还需要 juc 下的 lock ?

以 juc 的 ReentrantLock 为例,
synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活,
synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就放弃.
synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个true 开启公平锁模式.
synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.

  1. 信号量听说过么?之前都用在过哪些场景下?

信号量, 用来表示 “可用资源的个数”. 本质上就是一个计数器.
使用信号量可以实现 “共享锁”, 比如某个资源允许 3 个线程同时使用, 那么就可以使用 P 操作作为加锁, V 操作作为解锁, 前三个线程的 P 操作都能顺利返回, 后续线程再进行 P 操作就会阻塞等待,直到前面的线程执行了 V 操作.

线程安全的集合类

JavaEE 初阶 -- 多线程进阶_第17张图片

Vector、Stack、HashTable 是线程安全的,但是不推荐使用

多线程环境使用 ArrayList

JavaEE 初阶 -- 多线程进阶_第18张图片

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

  2. 使用标准库里面提供的一个 套壳操作: Collections.synchronizedList(new ArrayList);

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

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

  1. 占用内存较多.
  2. 新写的数据不能被第一时间读取到
    JavaEE 初阶 -- 多线程进阶_第19张图片

多线程环境使用队列

  • BlockingQueue

多线程环境使用哈希表

首先HashMap本身就线程不安全,不能在多线程中使用。

  1. HashTable 是线程安全的,也是给关键方法,加synchronized(加到方法上,就相当于针对 this 加锁了)
  2. ConcurrentHashMap(推荐使用)

HashTable 和 ConcurrentHashMap 的区别

  1. 加锁粒度不同

HashTable 是针对整个哈希表加锁,任何增删改查操作,都会触发加锁,也就都会可能有锁竞争
JavaEE 初阶 -- 多线程进阶_第20张图片
JavaEE 初阶 -- 多线程进阶_第21张图片

  1. 其它方面的改进

JavaEE 初阶 -- 多线程进阶_第22张图片
由此得出以下结论:
1、ConcurrentHashMap 为了减少了锁冲突,给每个链表的头节点上进行加锁。(锁桶)
2、在 JDK 8 之前,ConcurrentHashMap 只是针对 写操作加锁了,读操作没有加锁,而只是使用了 volatile 关键字,来避免“内存可见性”的问题。而在之后,在读取数据时也采用了一定的锁机制。
3、ConcurrentHashMap 中更广泛的使用了 CAS,进一步提高效率。
(比如维护 size【元素个数】 操作)
4、ConcurrentHashMap 针对扩容,进行了巧妙的化整为零。

相关面试题

  1. ConcurrentHashMap的读是否要加锁,为什么?

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

  1. 介绍下 ConcurrentHashMap的锁分段技术?

这个是 Java1.7 中采取的技术. Java1.8 中已经不再使用了. 简单的说就是把若干个哈希桶分成一个"段" (Segment), 针对每个段分别加锁.目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争.

  1. ConcurrentHashMap在jdk1.8做了哪些优化?

取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象).将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于8 个元素)就转换成红黑树

  1. Hashtable和HashMap、ConcurrentHashMap 之间的区别?

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

  1. 谈谈 volatile关键字的用法?

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

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

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

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

创建线程池主要有两种方式:
通过 Executors 工厂类创建. 创建方式比较简单, 但是定制能力有限.
通过 ThreadPoolExecutor 创建. 创建方式比较复杂, 但是定制能力强.
LinkedBlockingQueue 表示线程池的任务队列. 用户通过 submit / execute 向这个任务队列中添
加任务, 再由线程池中的工作线程来执行任务

  1. Java线程共有几种状态?状态之间怎么切换的?

NEW: 安排了工作, 还未开始行动. 新创建的线程, 还没有调用 start 方法时处在这个状态.
RUNNABLE: 可工作的. 又可以分成正在工作中和即将开始工作. 调用 start 方法之后, 并正在
CPU 上运行/在即将准备运行 的状态.
BLOCKED: 使用 synchronized 的时候, 如果锁被其他线程占用, 就会阻塞等待, 从而进入该状态.
WAITING: 调用 wait 方法会进入该状态.
TIMED_WAITING: 调用 sleep 方法或者 wait(超时时间) 会进入该状态.
TERMINATED: 工作完成了. 当线程 run 方法执行完毕后, 会处于这个状态

  1. 在多线程下,如果对一个数进行叠加,该怎么做?

使用 synchronized / ReentrantLock 加锁
使用 AtomInteger 原子操作

  1. Servlet是否是线程安全的?

Servlet 本身是工作在多线程环境下.
如果在 Servlet 中创建了某个成员变量, 此时如果有多个请求到达服务器, 服务器就会多线程进行
操作, 是可能出现线程不安全的情况的

  1. Thread和Runnable的区别和联系?

Thread 类描述了一个线程.
Runnable 描述了一个任务.
在创建线程的时候需要指定线程完成的任务, 可以直接重写 Thread 的 run 方法, 也可以使用Runnable 来描述这个任务

  1. 多次start一个线程会怎么样

第一次调用 start 可以成功调用.
后续再调用 start 会抛出 java.lang.IllegalThreadStateException 异常

  1. 有synchronized两个方法,两个线程分别同时用这个方法,请问会发生什么?

synchronized 加在非静态方法上, 相当于针对当前对象加锁.
如果这两个方法属于同一个实例:
线程1 能够获取到锁, 并执行方法. 线程2 会阻塞等待, 直到线程1 执行完毕, 释放锁, 线程2 获取到锁之后才能执行方法内容.
如果这两个方法属于不同实例:
两者能并发执行, 互不干扰

  1. 进程和线程的区别?

进程是包含线程的. 每个进程至少有一个线程存在,即主线程。
进程和进程之间不共享内存空间. 同一个进程的线程之间共享同一个内存空间.
进程是系统分配资源的最小单位,线程是系统调度的最小单位。

你可能感兴趣的:(java-ee,java)