Java多线程(四)锁策略(CAS,死锁)和多线程对集合类的使用

锁策略(CAS,死锁)和多线程对集合类的使用

 锁策略

1.乐观锁VS悲观锁

2.轻量级锁VS重量级锁

3.自旋锁VS挂起等待锁

4.互斥锁VS读写锁

5.可重入锁vs不可重入锁

死锁的第一种情况

死锁的第二种情况

死锁的第三种情况

CAS

1.实现原子类

2.实现自旋锁

偏向锁:非必要,不加锁

锁消除

锁粗化

Callable 的用法

JUC(ava.util.concurrent)

原子类

信号量 Semaphore

CountDownLatch

多线程对集合类的使用

多线程环境使用 顺序表

多线程环境使用队列

多线程环境使用哈希表

其他方面的改进:

更充分的利用了CAS机制--无锁编程

优化了扩容策略


 锁策略

上面我说过,锁是为了解决线程冲突的问题。但是我也说过加锁操作会影响程序的效率。(因为阻塞),为了应对这个我们应该合理去进行加锁操作,那么就应该有策略的操作。

1.乐观锁VS悲观锁

乐观锁:   预测接下来冲突概率不大(做的工作少)--->效率会快一些

悲观锁:预测接下了的冲突概率不大(做的多)--->x效率会慢一些

其实这两个就是预测接下来的锁冲突(阻塞等待)的概率是大,还是不大,根据这个冲突的概率,决定接下来怎么做。

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

2.轻量级锁VS重量级锁

轻量级锁:加锁解锁的过程更快更高效。(一个乐观锁很可能是一个轻量级锁)

重量级锁:加锁解锁,过程更慢,更低效。(一个悲观锁很可能是一个重量级锁)

3.自旋锁VS挂起等待锁

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

加锁失败后,不停等待的去问是否可以加锁了

挂起等待锁:是重量级锁的一种典型实现(通过内核机制来实现挂起等待(时间更长了))

加锁失败后,先去做其他事情,等这个锁给我信号后我就回来加锁。

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

Synchronized 会根据当前锁竞争的激烈程度,自适应;

  • 如果冲突不激烈,以轻量级锁或者乐观锁的状态运行
  • 如果激烈,以重量级锁或悲观锁的状态运行。

4.互斥锁VS读写锁

互斥锁:

synchronized是一个互斥锁,就单纯的加锁。通常只有两种操作:

  1. 进入代码块,加锁
  2. 出代码块,解锁

读写锁:

有一种锁,把读操作和写操作分开加锁(线程安全):

  1. 给读加锁
  2. 给写加锁
  3. 解锁

约定:

  1. 读锁和读锁之间,不会锁竞争,不会产生冲突(不会影响程序之间的效率)
  2. 写锁和写锁之间,有锁竞争(减慢速度,保证准确性)
  3. 读锁和写锁之间,有锁竞争(减慢速度,保证准确性)

Java中专门提供了读锁一个类,写锁一个类。

5.可重入锁vs不可重入锁

  • 如果一个锁,在一个线程中,连续对锁,锁了两次,不产生死锁,叫可重入锁。
  • 如果一个锁,在一个线程中,连续对锁,锁了两次,产生死锁,叫不可重入锁。

死锁的第一种情况

如何产生死锁,我们对一个代码加两次锁,此时内部的锁要等待外部的锁释放才能加锁,而此时外部的锁释放,需要等待内部锁加锁成功。然后逻辑上矛盾了,于是产生了死锁。

死锁的第二种情况

两个线程两把锁,即使单个线程是可重入锁,也会死锁。

 线程1的外部锁加锁,需要等待线程2内部锁释放,同理线程2外部锁加锁,需要等待线程1内部锁释放,此时逻辑矛盾,产生死锁。

死锁的第三种情况

哲学家,就餐问题(N个线程,M把锁)

一个桌子上有五只筷子。也有五个人,桌上有一碗面,每个人只能用一双筷子吃一口。诺是五个同时拿起一只筷子,场上就构不成一双筷子的条件,也就是谁都吃不了面。此时就死锁了。

 怎么办,很简单,五个人约定一个规则,谁先吃,谁后吃,此时就可以避开死锁的情况。

死锁的四个必要条件

  • 互斥使用:一个线程拿到一把锁后,另一个线程不能使用(根本问题锁的基本特点)
  • 不可抢占:一个线程拿到锁,只能自己主动释放,不能是被其他线程强行占有
  • 请求和保持:一个线程拿到一个锁,不去做事,反而想拿到第二把锁。
  • 循环等待:逻辑冲突。谁都拿不到。

实践中如何避免死锁?

对锁进行编号,如果需要获取多把锁,就约定加锁顺序,务必先对编号小的加锁,在对编号大的加锁。

公平锁VS非公平锁

约定:

遵循先来后到,就是公平锁,不遵守先来后到的(等概率竞争是不公平的),非公平锁。

synchronized是非公平的,要实现公平就需要在synchronized的基础上,加个队列来记录这些加锁线程的顺序。

总结一下synchronized的特点:

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

CAS

CAS: 全称Compare and swap,字面意思:“比较并交换”,一个 CAS 涉及到以下操作:

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

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

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

3. 返回操作是否成功。

真实的 CAS (即cpu的一条指令)是一个原子的硬件指令完成的(具有原子性),相当于我们不加锁,就能保证线程安全。

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

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

讲到锁操作的时候,我们说过因为一个读一个写的两个线程,他们不会自己去检查变量是否发生过改变。但是CAS却可以进行自检,并返回是否成功。

基于CAS实现的操作:

1.实现原子类

标准库里提供AtomInteger类保证程序的原子性

  • AtomicBoolean
  • AtomicInteger
  • AtomicIntegerArray
  • AtomicLong
  • AtomicReference
  • AtomicStampedReference
2.实现自旋锁

通过CAS的自检性,反复检查当前的锁状态,看是否解开了;

但是CAS不是没有问题,最典型的问题A->B->A问题,其实就是我们要内存改变的值与内存的值一样,是得不断在A--B--A中不断横跳。在具体一点就是,两个线程(t1,t2)对数据进行减法,(t3)还有一个对数据进行加法,而加的数据与减的数据一样。

那么就会有一个问题。两个线程中其中一个线程(t1)提前做了减操作,接下来是(t3)加操作,此时内存的值没变,t2线程发现值是原来的值,又做了一次减操作。(这显然不是我们所期望的)

如何解决呢?

加入一个衡量内存的值是否变化的量,俗称版本号,版本号只能增加无法减少,每一次修改版本+1,这样我只需对比版本号本身就可以避免aba问题。

synchronized的锁策略:锁升级

偏向锁:非必要,不加锁

先让线程针对锁,有个标记,如果整个代码执行过程中没有遇到别的线程和我竞争这个所,我就加锁了。但是如果有人来竞争,就升级为真的锁。这样既保证了效率,也保证了线程安全。

锁消除

基础逻辑是,非必要不加锁。编译器+JVM 判断锁是否可消除,如果可以,就直接消除。检测当前代码是否多线程执行,判断是否有必要加锁,如果没有必要,但是又加上了锁,就会在编译过程中自动取消掉。

比如StringBuffer,在源码内加入了synchronized关键字。诺是单线程就必要加锁了,也就可以取消掉。

锁粗化

锁的粒度,synchronized代码块,包含代码的多少(代码越多,粒度越粗,越少,粒度越细),多数情况希望锁的粒度更小。(串行代码少,意味着并发代码就多。)

如果有一个场景需要频繁的加锁解锁,此时就会将整个场景锁起来,变成一个更粗的锁

Callable 的用法

Callable 是一个 interface . 相当于把线程封装了一个 "返回值". 方便程序猿借助多线程的方式计算结果,非常类似于Runnable,只不过返回值不是void,而是泛型

创建线程计算 1 + 2 + 3 + ... + 1000(非callable)

//创建一个类 Result , 包含一个 sum 表示最终结果, lock 表示线程同步使用的锁对象
static class Result {
    public int sum = 0;
    public Object lock = new Object();
}
public static void main(String[] args) throws InterruptedException {

    Result result = new Result();
    Thread t = new Thread() {
        @Override
        public void run() {
//main 方法中先创建 Result 实例, 然后创建一个线程 t. 在线程内部计算 1 + 2 + 3 + ... + 1000
            int sum = 0;
            for (int i = 1; i <= 1000; i++) {
                sum += i;
           }
            synchronized (result.lock) {
                result.sum = sum;
//主线程同时使用 wait 等待线程 t 计算结束
                result.lock.notify();
           }
       }
   };
    t.start();
    synchronized (result.lock) {
        //
        while (result.sum == 0) {
            result.lock.wait();
       }
//当线程 t 计算完毕后, 通过 notify 唤醒主线程, 主线程再打印结果.
        System.out.println(result.sum);
   }
}

创建线程计算 1 + 2 + 3 + ... + 1000(callable)

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;
   }
};
FutureTask<Integer> futureTask = new FutureTask<>(callable);
Thread t = new Thread(futureTask);
t.start();
int result = futureTask.get();
System.out.println(result);
  1. 创建一个匿名内部类, 实现 Callable 接口. Callable 带有泛型参数. 泛型参数表示返回值的类型.
  2. 重写 Callable 的 call 方法, 完成累加的过程. 直接通过返回值返回计算结果.
  3. 把 callable 实例使用 FutureTask 包装一下.
  4. 创建线程, 线程的构造方法传入 FutureTask . 此时新线程就会执行 FutureTask 内部的 Callable 的
  5. call 方法, 完成计算. 计算结果就放到了 FutureTask 对象中.
  6. 在主线程中调用 futureTask.get() 能够阻塞等待新线程计算完毕. 并获取到 FutureTask 中的结 果.

Callable中泛型是什么,就返回什么。

Callable 和 Runnable的区别

  1. Callable 和 Runnable 相对, 都是描述一个 "任务",Callable 描述的是带有返回值的任务,Runnable 描述的是不带返回值的任务。
  2. Callable 通常需要搭配 FutureTask 来使用.,FutureTask 用来保存 Callable 的返回结果. 因为Callable 往往是在另一个线程中执行的, 啥时候执行完并不确定。
  3. FutureTask 就可以负责这个等待结果出来的工作。

FutureTask 的理解,其实可以理解为,炖汤,通常炖汤我们将食物放入砂锅中,只需要等待时间过去2-3小时,砂锅就能为我们呈现一锅鲜美的汤。

JUC(ava.util.concurrent

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

用法:

  1. lock(): 加锁, 如果获取不到锁就死等.
  2. trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.(加锁失败,不会阻塞,直接返回false,更灵活)
  3. unlock(): 解锁

ReentrantLock 和 Synchronized 的区别:

  1. synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准 库的一个类, 在 JVM 外实现的(基于 Java 实现).
  2. synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock.
  3. synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就 放弃.
  4. synchronized 是非公平锁, ReentrantLock 默认是非公平锁,但是提供了公平和非公平两种工作模式. 可以通过构造方法传入一个 true 开启公平锁模式.
  5. 更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一 个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程.

如何选择使用哪个锁?

  • 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便。
  • 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等。
  • 如果需要使用公平锁, 使用 ReentrantLock。

原子类

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

  • AtomicBoolean
  • AtomicInteger
  • AtomicIntegerArray
  • AtomicLong
  • AtomicReference
  • AtomicStampedReference

信号量 Semaphore

本质是一个计数器,描述了当前“可用资源”的个数

  • P操作,申请资源。计数器-1;
  • V操作,释放资源。计数器+1;

如果计数器为0,就阻塞等待,等待出现资源时,及继续申请等待。

  • 创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源。
  • acquire 方法表示申请资源(P操作), release 方法表示释放资源(V操作)
  • 创建 20 个线程, 每个线程都尝试申请资源, sleep 1秒之后, 释放资源. 观察程序的执行效果。
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
    @Override
    public void run() {
        try {
            System.out.println("申请资源");
            semaphore.acquire();
            System.out.println("我获取到资源了");
            Thread.sleep(1000);
            System.out.println("我释放资源了");
            semaphore.release();
       } catch (InterruptedException e) {
            e.printStackTrace();
       }
   }
};
for (int i = 0; i < 20; i++) {
    Thread t = new Thread(runnable);
    t.start();
}

CountDownLatch

同时等待 N 个任务执行结束.

  • 构造 CountDownLatch 实例, 初始化 10 表示有 10 个任务需要完成。
  • 每个任务执行完毕, 都调用 latch.countDown() . 在 CountDownLatch 内部的计数器同时自减。
  • 主线程中使用 latch.await(); (暗中计算有几个countDown被调用了)阻塞等待所有任务执行完毕. 相当于计数器为 0 了。
public class Demo {
    public static void main(String[] args) throws Exception {
        CountDownLatch latch = new CountDownLatch(10);
        Runnable r = new Runable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(Math.random() * 10000);
                    latch.countDown();
               } catch (Exception e) {
                    e.printStackTrace();
               }
           }
       };
        for (int i = 0; i < 10; i++) {
            new Thread(r).start();
       }
   // 必须等到 10 人全部回来
        latch.await();
        System.out.println("比赛结束");

   }
}

多线程对集合类的使用

常用的集合类:ArrayList,LinkedList,HashMap,PriorityQueue。。。线程是不安全的。

如果要使用怎么办?

1.可以手动对集合的修改操作加锁。(synchronized 或者 ReentrantLock)

2.使用java标准库提供的一些线程安全的版本的集合类。

多线程环境使用 顺序表

ArrayList可用,Vertor代替,但是vertor该有的方法都用synchronized,是很老的集合,实际场景并不适用。

1.Collections.synchronizedList(new ArrayList);

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

2.使用 CopyOnWriteArrayList

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

  • 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy, 复制出一个新的容器,然后新的容器里添加元素,
  • 添加完元素之后,再将原容器的引用指向新的容器。

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

添加任何元素。

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

优点:

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

缺点:

  1. 占用内存较多.
  2.  新写的数据不能被第一时间读取到.

多线程环境使用队列

  1.  ArrayBlockingQueue      基于数组实现的阻塞队列
  2.  LinkedBlockingQueue     基于链表实现的阻塞队列
  3.  PriorityBlockingQueue     基于堆实现的带优先级的阻塞队列
  4.  TransferQueue     最多只包含一个元素的阻塞队列

多线程使用队列:BlockingQueue  

多线程环境使用哈希表

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

  • Hashtable

是线程安全的,给关键方法加上synchronized,颗粒度比较粗。它对整个哈市表加锁,任何的增删查操作,都会触发加锁,也就意味着会有锁竞争。其实没有必要,哈希表是有桶的,修改值是要通过key计算hash值,然后将新元素放到链表上。

两个线程对不同量进行修改,不会产生冲突,但是由于方法上加了锁也就意味着,两个线程同时使用一个方法会阻塞。(所以不建议)

  • ConcurrentHashMap

线程是安全的,ConcurrentHashMap不是只有一把锁了,每个桶也就是链表的头结点作为一把,锁,这样针对不同的链表进行操作是不会产生的所冲突。大部分的加锁操作就没有锁冲突。

其他方面的改进:
更充分的利用了CAS机制--无锁编程

有些操作,比如获取或更新某个元素个数,就可以直接使用CAS完成,不必加锁

优化了扩容策略

对于hashTable来说,如果元素太多们就会涉及扩容,诺元素很多很多,上亿个,那么将原表大部分的元素搬到新的位置上,这个操作非常不流畅。所以呢ConcurrentHashMap,在此基础上,诺put触发扩容机制,就会一次性创建更大的内存空间,然后搬运一部分,此时就相当于存在两个hash表,此时对表操作,插入是对新表插入,删除是对旧表(看元素在那个表上)删除,查找是新旧表都查。(每一次操作。都会从旧表搬运一部分到新表)

Hashtable和HashMap、ConcurrentHashMap 之间的区别

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

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

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

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

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

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

LinkedBlockingQueue 表示线程池的任务队列。 用户通过 submit / execute 向这个任务队列中添

加任务, 再由线程池中的工作线程来执行任务。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值