JavaEE 【知识改变命运】06 多线程进阶(1)

文章目录

一常见的锁

乐观锁和悲观锁

  • 乐观锁

    乐观锁执行任务前预期没有锁竞争或者锁的竞争不激烈就不会添加锁,随着竞争的激烈程度加深,才逐渐锁升级

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

  • 悲观锁

    悲观锁执行任务前就预期锁竞争很激烈,一开始就进行上锁操作,先上锁再执行任务

    总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

  • 举例:A同学和B同学去请教老师问题

    A同学认为老师比较忙,我来问问题,老师不一定有空解答,因此A同学会先给老师发消息"老师你忙吗"我下午两点来能找你问个问题妈(相当于加锁操作),得到肯定的答复后,才会真正的来问问题,如果得到了否定的答复,那就等一段时间,下次再来和老师确定时间,这个就是悲观锁

    同学 B 认为 "老师是比较闲的, 我来问问题, 老师大概率是有空解答的". 因此同学 B 直接就来找老师.(没加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B 也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁

  • 这两种思路不能说谁优谁劣, 而是看当前的场景是否合适.

    如果当前老师确实比较忙, 那么使用悲观锁的策略更合适, 使用乐观锁会导致 "白跑很多趟", 耗费额外的资源.

    如果当前老师确实比较闲, 那么使用乐观锁的策略更合适, 使用悲观锁会让效率比较低

轻量级锁和重量级锁

  • 轻量级锁

    加锁过程比较简单,消耗资源少,典型就是用户态的一些操作(java层面就可以完成)

  • 重量级锁

    加锁过程比较复杂,消耗资源比较多,典型就是内核态的一些操作

  • 刨析底层

  • 锁的核心特性 "原子性", 这样的机制追根溯源是 CPU 这样的硬件设备提供的
    1.CPU提供了原子性操作指令
    2.操作系统基于CPU的原子指令,实习了mutex互斥锁
    3.JVM基于操作系统提供了互斥锁,实现了 synchronized 和ReentrantLock 等关键字和类
    4.注意:sychronized并不仅仅对imutex进行封装,内部还做了很多其他的工作
  • 重量级锁:加锁机制依赖的是OS(操作系统)提供了mutex
    1.大量的内核态和用户态来回切换
    2.容易引发的线程的调度
    3.这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着 "沧海桑田".
  • 轻量级锁: 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex
    1.少量的内核态用户态切换.
    2.不太容易引发线程调度
  • 理解用户态和内核态
    去银行办理业务,如果光取前就去自主提款机,这就是用户态,取钱的时间自己可以把控,而且节省时间成本,去柜台办理取钱工作,我们就需要跟柜台人员来回交流,而且还要排队,时间成本大大增加。

自旋锁和挂起等待锁

  • 挂起等待锁
    如果发生阻塞,等待被唤醒,挂起锁不能感应锁的是否是释放的,需要被唤醒,内核态操作,会生产对应的锁指令,等待被唤醒,在等待过程中CPU会释放资源
  • 自旋锁
    不停的检查锁是否被释放,锁一旦被释放第一时间获取锁资源,纯用户态操作,有自旋次数和时间限制,通过这个限制可以控制对系统资源的消耗,可以第一时间获取锁。
  • 举例
    我和女朋友约会周五去玩,我到她楼下,她说她要化妆
    自旋锁,就是我不停的给她打电话,问她有没有好,如果她好了,我可以第一时间知道,挂起锁,我就是在楼下开了一把王者,她好没好,我不清楚,她好了之后下楼,把我叫了一声,我才知道她好了。
  • 自旋锁是一种典型的轻量级锁
    优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁
    缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的).
    synchronized 中的轻量级锁策略大概率就是通过自旋锁的方式实现的.

读写锁和普通互斥锁

  • 普通互斥锁
    有竞争关系,只有一个线程释放锁之后,另一个线程才可以过来强,写锁就是一个互斥锁
  • 读锁(共享锁)
    读操作的时候加读锁(共享锁),多个读锁可以共存,同时加多个读锁相互不影响
    写操作的时候加写锁(互斥锁),只允许一个写锁执行任务,和其他锁是冲突的

写锁和写锁不能共存, 两个线程都要写一个数据,存在线程安全问题

读锁和写锁不能以共存, 一个要写数据一个要读数据,存在线程安全问题

读锁和读锁可以共存 ,两个线程要读一个数据,不存在线程安全问题,直接并发读就好了。

  • 读写锁就是把读操作和写操作区分对待. Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.
    1.ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.
    ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.
  • 适用场景
    读写锁特别适合于 "频繁读, 不频繁写" 的场景中. (这样的场景其实也是非常广泛存在的).比如我们去看某小说上面的网站,可能每天有成天上万的人来看小说,但是作者可能每个月才去网站上面更新一次,这是我们设置读锁,并行这些用户去读小说,提高了效率,如果设置为写锁,只能一个人读完释放锁之后另一个才能读,效率低下
    注意:只要产生了互斥,就会产生线程被挂起来等待,一旦被挂起来等待就不知道隔多长时间才能被唤醒,因此减少互斥,是提升效率的重要途径。
  • Synchronized 不是读写锁

公平锁和不公平锁

  • 公平锁
    遵守先来后到,先排队的线程先拿到锁,后排队的线程后拿到锁
  • 非公平锁
    没有先后顺序,谁抢到锁就是谁的
  • 两锁的对比
    操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序.
    公平锁和非公平锁没有好坏之分, 关键还是看适用场景
    JAVA中JUC中有一个类专门实现了公平锁
    sychronized是一个非公平锁

可重入锁和不可重入锁

  • 可重入锁:
    允许同一个线程多次获取同一把锁,可以成功解锁,不会把自己锁死
    Java中只要以Reentrant开头的命名的锁都是可重入锁,而且JDK提供的所有线程的Lock实现类,包括synchronized关键字锁都是可重入的。
  • 不可重入锁
    而 Linux 系统提供的 mutex 是不可重入锁.
    理解就是把自己锁死了

sycnchroized是什么锁

  • 是什么锁
    1.即使乐观锁也是悲观锁
    2.即使轻量级锁也是重量级锁
    3.即使自旋锁也是挂起等待锁
    4.是互斥锁
    5.是写锁
    6.是可重入锁
  • 为什么是这么多锁呢?
    这个主要是根据sychonized的竞争激烈程度来决定的
    sychronized在锁竞争不激烈的时候,就是乐观锁,轻量级锁,自旋锁
    sychronized在锁竞争激烈的时候,是悲观锁,重量级锁,挂起等待锁
    但是在sychronized的内部大牛已经实现好了,我们不用去管竞争的是否激烈,他会自己判断的

CAS

什么是CAS

  • CSA:全称叫Compara and swap ,比较并交换
  • 他涉及的操作
    我们假设内存中原数据是V,旧的预期值A,需要修改的新值是B
    1:比较V和A
    2:如果相等,将B与V交换
    3:返回操作成功

CAS伪代码

  • 下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解CAS 的工作流程
sql 复制代码
boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
   &address = swapValue;
        return true;
   }
    return false;
}

执行流程:address里面的值和expectValue比较,如果相等,九江要设置的新值跟内存里面的值更新,如果不相等,就进行下一次比较

  • 当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。
    CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式)

CSA是怎么实现的

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

  • java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作;
  • unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg;
  • Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性。
    简而言之,是因为硬件予以了支持,软件层面才能做到。

CAS如何保证线程安全的呢

  • 我们先回忆一下线程安全出现的原因
  • CAS是如何保证的呢?

实现自旋锁

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

sql 复制代码
自旋锁伪代码
public class SpinLock {
    private Thread owner = null;
    public void lock(){
        // 通过 CAS 看当前锁是否被某个线程持有. 
        // 如果这个锁已经被别的线程持有, 那么就自旋等待. 
        // 如果这个锁没有被别的线程持有, 那么就把 owner 设为当前尝试加锁的线程. 
        while(!CAS(this.owner, null, Thread.currentThread())){
       }
   }
    public void unlock (){
        this.owner = null;
   }
}

JDK中提供的使用自旋锁的方式处理锁竞争

CAS



sychronized锁升级

升级的过程

  • JVM 将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 状态。会根据情况,进行依次升级。
  1. 偏向锁
    第一个尝试加锁的线程, 优先进入偏向锁状态

偏向锁不是真的 "加锁", 只是给对象头中做一个 "偏向锁的标记", 记录这个锁属于哪个线程.

如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销)

如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别

当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.

偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.

但是该做的标记还是得做的, 否则无法区分何时需要真正加锁
举个栗子理解偏向锁

假设男主是一个锁, 女主是一个线程. 如果只有这一个线程来使用这个锁, 那么男主女主即使不领证

结婚(避免了高成本操作), 也可以一直幸福的生活下去.

但是女配出现了, 也尝试竞争男主, 此时不管领证结婚这个操作成本多高, 女主也势必要把这个动作

完成了, 让女配死心

  1. 轻量级锁

随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁).

此处的轻量级锁就是通过 CAS 来实现

-- 通过 CAS 检查并更新一块内存 (比如 null => 该线程引用)

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

-- 如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU).

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

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

也就是所谓的 "自适应"

  1. 重量级锁
    如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁,此处的重量级锁就是指用到内核提供的 mutex .

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

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

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

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

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

这个线程, 尝试重新获取锁

  1. 现实举例

    这里补充一点:在jdk17取消了偏向锁,在jdk8依旧保留着偏向锁

其他的优化操作

锁消除

编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除

程序员写代码时候,JVM是管不了什么时候加锁和不加锁的。但是当代码编译之后运行的时候JVM就知道synchronizied的代码是对变量的读还是些,还知道当前是单线程还是多线程,

如果所有加synchronizied的代码块,并没有对变量进行写操作,或者是单线程执行,那么synchronizied对应的锁就不会生效(不会编译成LOCK)

这个发生情况,只有当JVM100%确定的时候才会执行锁消除操作,并不一定所有的代码都会发生

锁粗化

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

举个栗子理解锁粗化

方式一:

打电话, 交代任务1, 挂电话.

打电话, 交代任务2, 挂电话.

打电话, 交代任务3, 挂电话.

方式二:

打电话, 交代任务1, 任务2, 任务3, 挂电话.

显然, 方式二是更高效的方案

Callable 接口

问题抛出

Callable 是一个 interface . 相当于把线程封装了一个 "返回值". 方便程序猿借助多线程的方式计算结果.

代码示例: 创建线程计算 1 + 2 + 3 + ... + 1000, 不使用 Callable 版本

创建一个类 Result , 包含一个 sum 表示最终结果, lock 表示线程同步使用的锁对象.

main 方法中先创建 Result 实例, 然后创建一个线程 t. 在线程内部计算 1 + 2 + 3 + ... + 1000.

主线程同时使用 wait 等待线程 t 计算结束. (注意, 如果执行到 wait 之前, 线程 t 已经计算完了, 就不必等待了).

当线程 t 计算完毕后, 通过 notify 唤醒主线程, 主线程再打印结果

sql 复制代码
public class Test {
    public static void main(String[] args) throws InterruptedException {
        Result result = new Result();
        //创建一个线程进行累加操作
        Thread t1=new Thread(()->{
            int sum=0;
            for(int i=0;i<1000;i++) {
                //执行累加
                sum+=i;
            }
            //为结果赋值
            result.sum=sum;
            //唤醒其他线程
            synchronized (result.lock){
                result.lock.notifyAll();
            }

        });
        //启动线程
        t1.start();
        //t1.join();也可以等待t1执行完毕
        synchronized(result.lock) {
            //检查累加是否完成
            while(result.sum==0) {
                //没有完成,等待结果
                result.lock.wait();
            }
        }
        //打印结果
        System.out.println(result.sum);
    }
}
class Result {
    //累加和
    public int sum;
    //锁对象
    public Object lock=new Object();
}

Callable和Runnable的对比

代码示例: 创建线程计算 1 + 2 + 3 + ... + 1000, 使用 Callable 版本

创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型.

重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果. 把 callable 实例使用 FutureTask 包装一下.

创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.

在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结果

定义Callable的三种方式:

sql 复制代码
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;
            }
        };
        FutureTask<Integer> futureTask=new FutureTask<>(callable);
        Thread t1=new Thread(futureTask);
        t1.start();
        int result=futureTask.get();
        System.out.println(result);
    }

可以看到, 使用 Callable 和 FutureTask 之后, 代码简化了很多, 也不必手动写线程同步代码了

  1. 理解 Callable

Callable 和 Runnable 相对, 都是描述一个 "任务". Callable 描述的是带有返回值的任务,

Runnable 描述的是不带返回值的任务.

Callable 通常需要搭配 FutureTask 来使用. FutureTask 用来保存 Callable 的返回结果. 因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定.

FutureTask 就可以负责这个等待结果出来的工作

  1. 理解 FutureTask

想象去吃麻辣烫. 当餐点好后, 后厨就开始做了. 同时前台会给你一张 "小票" . 这个小票就是

FutureTask. 后面我们可以随时凭这张小票去查看自己的这份麻辣烫做出来了没

  1. 面试题

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

ReentrantLock

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

ReentrantLock 也是可重入锁. "Reentrant" 这个单词的原意就是 "可重入"

sql 复制代码
 public static void main(String[] args) throws InterruptedException {
        //创建一个ReentrantLock对象
        ReentrantLock lock=new ReentrantLock();
        //加锁
        lock.lock();
        //解锁
        lock.unlock();
        //尝试加锁,如果加锁成功,返回true,否则返回false
        lock.tryLock();
        //尝试加锁,如果加锁成功,并且可以指定等待时间
        lock.tryLock(1, TimeUnit.SECONDS);

    }

但是这种情况有个问题,如果加锁后执行异常,就一种无法解锁,所以改进代码。

sql 复制代码
public static void main(String[] args) throws InterruptedException {
        //创建一个ReentrantLock对象
        ReentrantLock lock=new ReentrantLock();
        //加锁
        try {
            lock.lock();
            System.out.println("业务代码");
            TimeUnit.SECONDS.sleep(10);
            throw new Exception("业务异常");
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            lock.unlock();
            System.out.println("释放锁");
        }
    }

ReentrantLock 和 synchronized 的区别

  • synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准库的一个类, 在 JVM 外实现的(基于 Java 实现)
  • synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock
  • synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过trylock 的方式等待一段时间就放弃.
  • synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启公平锁模式
sql 复制代码
// ReentrantLock 的构造方法
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}
//实现
 ReentrantLock lock=new ReentrantLock(true);
  • 更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程
sql 复制代码
public static void main(String[] args) throws InterruptedException {
        //定义一把锁
        ReentrantLock lock=new ReentrantLock();
        //定义很多个唤醒条件和休眠条件
        //条件1
        Condition male=lock.newCondition();
        //条件2
        Condition female=lock.newCondition();
        lock.lock();
        new Thread(() -> {
            while (true) {
                lock.lock();
                male.signal();
                female.signal();
                lock.unlock();
            }
        }).start();
        lock.lock();
        male.await();
        lock.unlock();
        System.out.println("男生来了");
        System.out.println("唤醒男生");
        female.await();
        System.out.println("女生来了");
        //唤醒条件1

        female.signal();
        System.out.println("唤醒女生");
    }
  • 创建读写锁
sql 复制代码
 public static void main(String[] args) {
        //创建一个读写锁
        ReentrantReadWriteLock lock=new ReentrantReadWriteLock();
        //创建一个读锁
        ReentrantReadWriteLock.ReadLock readLock=new ReentrantReadWriteLock().readLock();
        //创建一个写锁
        ReentrantReadWriteLock.WriteLock writeLock=new ReentrantReadWriteLock().writeLock();
        
        //读锁加锁,共享锁,多个读锁可以共存
        readLock.lock();
        //写锁加锁,独占锁,一个写锁只能有一个
        writeLock.lock();
    }


原子类

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

AtomicBoolean

AtomicInteger

AtomicIntegerArray

AtomicLong

AtomicReference

AtomicStampedReference

以 AtomicInteger 举例,常见方法有

addAndGet(int delta); i += delta;

decrementAndGet(); --i;

getAndDecrement(); i--;

incrementAndGet(); ++i;

getAndIncrement(); i++;

信号量 Semaphore

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

理解信号量

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

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

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

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

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

代码示例

创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源. acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)

创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果

sql 复制代码
public class Test {
    public static void main(String[] args) {
        Semaphore semaphore = new Semaphore(4);
        for (int i = 0; i <20 ; i++) {
            new Thread(() -> {

                try {
                    semaphore.acquire();
                    System.out.println(Thread.currentThread().getName()+"申请资源");
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println(Thread.currentThread().getName()+"获得资源");
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                semaphore.release();
                System.out.println(Thread.currentThread().getName()+"释放资源");
            }, "线程" + i).start();
        }
    }
}

CountDownLatch

  • 同时等待 N 个任务执行结束
  • 场景:好像跑步比赛,10个选手依次就位,响声响才同时出发,所有选手都通过终点,才能公布成绩

构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成.

每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减.

主线程中使用 latch.await(); 阻塞等待所有任务执行完毕. 相当于计数器为 0 了

sql 复制代码
public static void main(String[] args) throws InterruptedException{
        CountDownLatch countDownLatch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {
            new Thread(()-> {
                System.out.println(Thread.currentThread().getName()+"开跑");
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println(Thread.currentThread().getName()+"到达终点");
                countDownLatch.countDown();
            },"运动员"+i+"号").start();
        }
        TimeUnit.SECONDS.sleep(1);
        System.out.println("比赛进行中...");

        countDownLatch.await();
        System.out.println("比赛结束颁奖");
    }

死锁

  • 产生死锁的四个必要条件
  1. 互斥使用:线程1拿到了锁资源,那么线程2就不能同时得到该锁(互斥锁),即当资源被一个线程使用(占有)时,别的线程不能使用
  2. 不可抢占:获取锁的线程,除非自己释放锁,别的线程不能从他手中抢过来了,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
    3.请求和保持:线程1已经获得了锁A,还要在这个基础上获取锁B,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
    4:线程1等待线程2释放锁,线程2等待线程3释放锁,线程3等待线程1释放锁,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路

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

  • 打破死锁
    上述4个条件中1,2我们是无法打破的,只能从3,4来入手
sql 复制代码
public static void main(String[] args) throws InterruptedException {
        Object lock1 = new Object();
        Object lock2 = new Object();
        new Thread(() -> {
            synchronized(lock1) {
                System.out.println("获得锁1");
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized(lock2) {
                    System.out.println("获得锁2");
                }
                System.out.println("释放锁2");
            }
            System.out.println("释放锁1");

        }).start();
        new Thread(() -> {
            synchronized(lock2) {
                System.out.println("获得锁2");
                synchronized(lock1) {
                    System.out.println("获得锁1");
                }
                System.out.println("释放锁1");
            }
            System.out.println("释放锁2");

        }).start();
    }

上面这段代码就产生了死锁情况。

我们从3和4的角度去修改一下获取锁的策略,修改代码如下:

sql 复制代码
public static void main(String[] args) throws InterruptedException {
        Object lock1 = new Object();
        Object lock2 = new Object();
        new Thread(() -> {
            synchronized(lock2) {
                System.out.println("获得锁2");
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized(lock1) {
                    System.out.println("获得锁1");
                }
                System.out.println("释放锁2");
            }
            System.out.println("释放锁1");

        }).start();
        new Thread(() -> {
            synchronized(lock2) {
                System.out.println("获得锁2");
                synchronized(lock1) {
                    System.out.println("获得锁1");
                }
                System.out.println("释放锁1");
            }
            System.out.println("释放锁2");

        }).start();
    }

}
  • 图解


线程安全的集合类

原来的集合类, 大部分都不是线程安全的

Vector, Stack, HashTable, 是线程安全的(不建议用), 其他的集合类不是线程安全的.

多线程环境使用 ArrayList

  • 问题显现:
sql 复制代码
public static void main(String[] args) {
        ArrayList<Integer> list = new ArrayList<>();
        for (int i = 0; i < 10; i++){
            int finalI = i;
            new Thread(() -> {
                list.add(finalI);
                System.out.println(list);
            }).start();

        }
    }
    1. 自己使用同步机制 (synchronized 或者 ReentrantLock)
sql 复制代码
public static void main(String[] args) throws InterruptedException {
         ArrayList<Integer> list = new ArrayList<>();
        Object lock = new Object();

        for (int i = 0; i < 10; i++){
            TimeUnit.MILLISECONDS.sleep(1);
            synchronized (lock) {
                int finalI = i;
                new Thread(() -> {
                    list.add(finalI);
                    System.out.println(list);
                }).start();
            }
        }
    }
    1. Collections.synchronizedList(new ArrayList);
      synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
      synchronizedList 的关键操作上都带有 synchronized
sql 复制代码
public static void main(String[] args) {
        List<Integer> arraylist = new ArrayList<>();
        List<Integer> list = Collections.synchronizedList (arraylist);
        for (int i = 0; i <10 ; i++) {
            int finalI = i;
            new Thread(() -> {
                list.add(finalI);
                System.out.println(list);
            }).start();
        }
    }
    1. 使用 CopyOnWriteArrayList

CopyOnWrite容器即写时复制的容器。

当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,

复制出一个新的容器,然后新的容器里添加元素,

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

这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会

添加任何元素。

所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。

优点:

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

缺点:

占用内存较多.

新写的数据不能被第一时间读取到.

sql 复制代码
 public static void main(String[] args) {
        CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
        for (int i = 0; i <10 ; i++) {
            int finalI = i;
            new Thread(() -> {
                list.add(finalI);
                System.out.println(list);
            }).start();
        }
    }

多线程环境使用队列

1.ArrayBlockingQueue

基于数组实现的阻塞队列

2.LinkedBlockingQueue

基于链表实现的阻塞队列

3.PriorityBlockingQueue

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

4.TransferQueue

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

多线程环境使用哈希表

  • HashMap 本身不是线程安全的
  • 在多线程环境下使用哈希表可以使用:
    -- ashtable
    -- ConcurrentHashMap
  • Hashtable
    只是简单的把关键方法加上了 synchronized 关键字

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

    4.HashTable相当于把整个HashTable锁住了,而真正操作的只是一个hash是桶

ConcurrentHashMap

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

读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁. 加锁的方式仍然是是用 synchronized, 但是不是锁整个对象, 而是 "锁桶" (用每个链表的头结点作为锁对象), 大大降低了锁冲突的概率.

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

优化了扩容方式: 化整为零发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去. 扩容期间, 新老数组同时存在. 后续每个来操作,ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小部分元素. 搬完最后一个元素再把老数组删掉. 这个期间, 插入只往新数组加. 这个期间, 查找需要同时查新数组和老数组

ConcurrentHashTable每个桶都有一把锁,只有两个线程同时访问同一个桶时候才会发生锁冲突

相关推荐
Best_Me0718 分钟前
最短路径C++
java·c++·算法
我叫啥都行23 分钟前
计算机基础知识复习12.20
java·jvm·笔记·后端·sql
Adellle31 分钟前
docker容器镜像拉取失败解决方案
java·docker·容器
zfoo-framework42 分钟前
【SpringBoot中SpringMVC服务之处理post请求】
java·spring boot·spring
陈大爷(有低保)1 小时前
mybatisPlus使用步骤详解
java·后端·mybatis
TT哇1 小时前
*【每日一题 基础题】 [蓝桥杯 2024 省 B] 好数
java·蓝桥杯
小张认为的测试1 小时前
如何更改 maven 指定的 java 版本 set JAVA_HOME=C:\Program Files\Java\jdk1.8
java·开发语言·maven
阿哈831 小时前
240004基于ssm+maven+mysql+Java的房屋租赁系统的设计与实现
java·mysql·maven
杨荧1 小时前
【开源免费】基于Vue和SpringBoot的靓车汽车销售网站(附论文)
java·前端·javascript·vue.js·spring boot·spring cloud·开源
龙少95431 小时前
【深入理解Maven】
java·maven