JAVA EE - 多线程进阶

上一章我们较为详细的讲解了多线程初阶的内容。这一章,我们要开始继续猛干多线程~~

    • 常见的锁策略

首先我们要明确一点,这里的锁策略不是语法内容,而是任何关于“锁”这个话题,都会牵扯到的这个锁策略

悲观锁 vs 乐观锁

这里的意思是站在锁发生冲突概率的预测这个角度上来看待的

悲观锁就是认为这里别人每次拿到数据都会发生修改,因此就提前加锁防止发生线程安全问题。而乐观锁则是先不加锁,如果真的发生了并发冲突问题,就会提供错误报告,让用户来解决它。

当然这俩类锁,并不是绝对的 只是相比较而言 对于锁冲突发生概率的预测来看的

轻量级锁 vs 重量级锁

这里以我的认知 就是以 其消耗的系统资源的大小来区分的

轻量级的锁 资源开销较小,效率更高

重量级的锁 资源开销较大,效率较低

自旋锁 vs 挂起等待锁

这里我们用一个简单的例子 来理解这俩个锁的含义:

有个女神在校园迷倒万众男生,这时候有俩位究极舔狗,一位叫小A,一位叫小B,但是女神已经和一位高富帅谈恋爱了,这里我们可以理解为 女神这一对象 被一个线程上锁了,A这个线程也想对女神这个对象进行上锁,但是他得等啊,这时候A选择了原地转圈的方式,也就是啥都不干,不去尝试和其他女生交流发展关系,一心一意的等待女神解锁。而B呢 虽然也想要与女神进行操作,但是B知道女神已经被加锁了,但是他并没有像A一样 一直在等着女神,而是尝试与其他女生交流。以上A就是自旋锁,B就是挂起等待锁。

A消耗了大量的精力(CPU资源)一直在等待女神(等待这个对象解锁),但是只要女神失恋了,他会立刻知道,并尝试加锁

B就比较聪明了,不会一直等(节省了大量的CPU资源)去其他的事了

自旋锁是典型的轻量锁,挂起等待锁是典型的重量级锁

自旋锁的优缺点
  1. 优点:没有放弃CPU,不涉及线程阻塞和调度,它可以立刻感知到对象的解锁状态,并立即尝试进行加锁操作,效率较高

  1. 缺点:如果一直没有解锁,就会浪费大量的CPU资源(挂起等待是不会浪费CPU资源的)

互斥锁 vs 读写锁

互斥锁就是 只有 加锁 解锁俩个操作,一个线程加锁了 另一个线程也尝试进行加锁,只能阻塞等待。

读写锁则是有三个操作:1.针对读操作进行加锁 2.针对写操作进行加锁 3.解锁

一个线程对于数据的访问主要就是读写俩操作,如果代码中 有读操作就加读锁,如果是有写操作 就加写锁。

  1. 读加锁和读加锁之间, 不互斥.

  1. 写加锁和写加锁之间, 互斥.

  1. 读加锁和写加锁之间, 互斥.

注意, 只要是涉及到 "互斥", 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多久了.因此尽可能减少 "互斥" 的机会, 就是提高效率的重要途径

因此读写锁适用在读操作频繁的情况下~~

公平锁 vs 非公平锁

用上一个例子继续解读:女神和高富帅分手之后(解锁之后)这时候 A和B都有机会了 但是如果是先来后到的情况下 就按时间顺序 谁先追的女神 女神就和那个人进行加锁,这就是公平锁,反之就是非公平锁。

如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖 额外的数据结构, 来记录线程们的先后顺序

可重入锁 vs 不可重入锁

可重入锁就是如果对一个对象进行俩次加锁就不会发生阻塞等待

不可重入锁就会发生阻塞等待

对synchronized进行辨别一下

1.它就是悲观锁也是乐观锁,默认是乐观 如果发现锁冲突比呼激烈的情况下,就会变成悲观锁

2.synchronized是轻量级锁,也是重量级

3.synchronized轻量级锁是自旋锁来实现的 重量级就是挂起等待锁实现的

4.不是读写锁

5.它是非公平锁

6.是可重入锁

    • CAS

何为CAS呢?翻译过来就是比较并交换,这里我们要注意的是,其内涵在于它是具有原子性的,只有一行的CPU指令,因此可以理解为 一定程度上帮助我们解决了线程安全问题。

我们假设内存中的原数据V,旧的预期值A,需要修改的新值B。
1. 比较 A 与 V 是否相等。(比较)
2. 如果比较相等,将 B 写入 V。(交换)
3. 返回操作是否成功。

2.1 CAS的应用场景

    • 实现原子类

标准库中提供了 java.util.concurrent.atomic 包, 里面的类都是基于这种方式来实现的.

典型的就是 AtomicInteger 类. 其中的 getAndIncrement 相当于 i++ 操作.

我们通过伪代码来理解一下CAS在实现原子类的重要作用。


class AtomicInteger {
    private int value;    //这里的value是固有属性 在内存里存在
    public int getAndIncrement() {
        int oldValue = value;        //oldvalue在寄存器中存在 先把内存中的值存到寄存器
        while ( CAS(value, oldValue, oldValue+1) != true) {    //利用CAS来判断二者是否相等
        //如果不相等的话 就更新数据 让它们相等之后在交换
            oldValue = value;
        }
        //返回oldvalue
        return oldValue;
    }
}

我们可以看到 这样的方式很好的实现了原子性,一定程度上代替了锁操作。

还有一些细节问题 我们通过画图来理解:

JAVA EE - 多线程进阶_第1张图片
注意:
CAS 是直接读写内存的, 而不是操作寄存器.
CAS 的读内存, 比较, 写内存操作是一条硬件指令, 是原子的
b.实现自旋锁
JAVA EE - 多线程进阶_第2张图片

2.2 CAS的ABA问题

所谓的ABA问题 就是相当于 我买了一个手机 不知道这个手机 是新机还是别人翻新过的机子。

就是说CAS核心代码就是判断value和oldvalue是否相等 但是这个value值是原本的值吗,还是已经修改过的值呢?

再举一个例子来解释一下这个问题:

小明去了趟银行取钱,他的账号有1000块钱 他要取500,自助取款机正在办理的时候,ATM机用俩个线程来完成扣钱的操作。我们预期是一个线程成功了,另一个失败。但是在这线程1执行完一瞬间(还没到线程2执行的时候),小刚给小明转去了500(在线程2扣钱这个动作之前 就已经存进去了 就是 账号余额是1000)这时候,CAS开始运行,发现值(oldvalue = 1000,value = 1000)是没变化,因此就认为没扣钱 继续扣钱,就相当于扣了俩次钱。

当然这个情况发生的概率是非常小的。

当然了,解决这个问题也是非常简单的,引入版本号这个东西,让CAS来比较版本号进行修改~~

    • Synchronized原理

synchronized内部有几个优化机制:

    • 锁升级
在代码进入sychronized语句的时候,它会有几个阶段:
1.无锁
2.偏向锁
3.轻量级锁
4.重量级锁

进行加锁的时候,就会首先进入偏向锁状态,这个时候并不是真正的锁,相当于只是放了一个标志,如果遇到竞争的时候,才会进入下一个状态——轻量级锁,这时候synchronized通过自选的方式来进行,但是我们知道 自旋锁是非常浪费CPU资源的 因此 如果我们一直等不到 解锁操作的话,就让它变成重量级锁,也就是挂起等待了~~

轻量级锁

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

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

如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.

偏向锁本质上相当于 "延迟加锁" . 能不加锁就不加锁, 尽量来避免不必要的加锁开销.但是该做的标记还是得做的, 否则无法区分何时需要真正加锁.

重量级锁就是就是基于操作系统原生的API来进行加锁,因为是操作系统提供加锁的,因此耗费的资源是比较大的,会影响线程的速度的,如果这个锁变成了重量级锁,并且又有其他线程来竞争,那这个锁就会进入阻塞队列 调出CPU。这个时候它的效率是非常底下的~~

b.缩消除

简单说就是 JVM给不需要加锁的地方自动给你删去了

c.锁粗化

就是该锁包含的代码多少,如果锁包含的代码越少就是说该锁的粒度 比较细~~

这个细还是粗还是看具体的应用场景的

实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁.
但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁

    • JUC

啥是juc呢?juc就是java.util.concurrent

这里面放了很多关于并发编程的相关组件,

4.1Callable接口

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

它其实是和Runnable接口一样的,只不过该接口描述任务的同时 还有一个返回值

JAVA EE - 多线程进阶_第3张图片

Thread构造的时候不能直接用callable传递 需要一个辅助类

完整代码如下:


public static void main(String[] args) throws InterruptedException, ExecutionException {
        Callable callable = new Callable() {
            public Integer call() throws Exception {
                int sum = 0;
                for(int i =0;i < 1000;i++){
                    sum++;
                }
                return sum;
            }
        };
        //FutureTask就是辅助类 进行承接的作用
        FutureTask futureTask = new FutureTask<>(callable);
        Thread t1 = new Thread(futureTask);
        t1.start();
        t1.join();
        Integer res = futureTask.get();
        System.out.println(res);
    }    

4.2 ReentrantLock

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

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

基本用法就是:


public static void main(String[] args) {
        ReentrantLock reentrantLock = new ReentrantLock();
        reentrantLock.lock();
        try {
            //工作区域
            //如果需要解锁 就直接 return 有finally撑腰 进行解锁
        }finally {
            reentrantLock.unlock();
        }
    }

ReentrantLock的优点
  1. 它提供了可设置公平锁的构造方法。

  1. 有个方法 trylock()可以设置等待时间 过了这段时间之后 自动解锁

  1. 它可以指定唤醒某个线程:

更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一
个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指
定的线程

它的锁对象就是这个实例 syn是()内

4.3 信号量 Semaphore

我们可以举个例子来理解一下这个概念:大商场基本上都有底下停车场,这时候我们进入一辆车的情况下,门口的标识牌显示:空余停车位 数字会减少一个,如果出去一辆车的话,就会增加一个。

这个时候 空余停车位就是信号量,信号量本质上就是计数器,它计数的是 可用资源的个数

可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源.
当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)
当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源

P:acquire

V:release

锁是信号量的特殊情况,信号量是锁的一般表达。

信号量的应用场景

就比如说一本书在图书馆的存量是20本 ,当一个人借走就 -1:release,还回去就是+1:acquire,如果总数变成0之后,其他人还想看的话 就只能等了。

    • 多线程的安全集合类

1.多线程环境使用Arraylist

如果想要Arraylist线程安全的话有以下几个方面来实现:

  1. 自己加锁,自己使用syn和ree等

b. Collections.synchronizedList(new ArrayList);

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

c.COW 写时拷贝

如果针对arraylist读操作的情况就不加锁,如果是在写操作的时候,则拷贝一份新的数组来进行存储,如果在此操作期间,遇上了读操作,那么读的还是旧数据,读完之后 修改之后 再继续将新的替换~~

替换:就是改变一下引用~~

优点:不需要加锁竞争

缺点:修改的数据不会立刻知道,占用内存多

2.多线程使用哈希表 *

HashMap是不安全的,HashTable是用synchronized给关键方法进行加锁的,但是还有一个更好的方法:ConcurrentHashMap

ConcurrentHashMap的优点(优化)是啥呢?

  1. 我们知道HashTable是对整一个关键方法全部套上了synchronized,但是并不是每一次操作都会发生锁竞争,因此concurrenthashmap是把这个大锁分成了很多把小锁

JAVA EE - 多线程进阶_第4张图片

这个时候如果是针对 1 2这里数据进行修改操作会发生线程安全问题的:next指向可能会出现问题。

那么这个时候是需要加锁的,也就是这俩数据如果是相邻的情况下,需要加锁操作。

但是如果不是相邻的情况下,不会发生锁冲突问题,但是HashTable确是给这个整个方法都加上了个锁,而concurrenthashmap的优点就是 只是在数组里每个头节点上加个锁,将每个头节点作为锁对象,如果俩个线程针对的是同一个锁对象 就会发生阻塞等待,如果不是一个锁对象,就无事发生。

  1. 对于读写操作比较激进,只针对写操作进行加锁,也就是说 读读之间 读写之间不会发生冲突,只有写写之间才能发生冲突。因此写操作是原子的

  1. 充分利用了CAS 削减了锁的数量 节省了大量的资源。

d.优化了扩容方式: 化整为零

发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.

扩容期间, 新老数组同时存在.

后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小

部分元素.

搬完最后一个元素再把老数组删掉.

这个期间, 插入只往新数组加.

这个期间, 查找需要同时查新数组和老数组

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