线程知多少~(下篇)

一. 常见的锁策略

首先声明,接下来的锁策略并不仅仅局限于java,任何与"锁"相关的话题,都可能会涉及到接下来的内容.

1.1 乐观锁&悲观锁

悲观锁 :
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
乐观锁:
假设数据一般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。

 一般情况下,实现悲观锁需要做更多的工作,乐观锁做的工作就比较少

 举个例子来说明~

对于大学生来说,能进入公司内部实习是一个梦寐以求的机会.同学A认为,面试某讯公司的竞争者并不多,因此他做的准备比较少,这就是"乐观锁";同学B认为,肯定有很多人挤破头想去某讯当实习生,因此为了这次面试,他闭关修炼了大半年,这就是"悲观锁"的实现.

1.2 轻量级锁&重量级锁

锁的核心特性 " 原子性 ", 这样的机制追根溯源是 CPU 这样的硬件设备提供的 .
  • CPU 提供了 "原子操作指令".
  • 操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
  • JVM 基于操作系统提供的互斥锁, 实现了 synchronized ReentrantLock 等关键字和类.

 

重量级锁 : 加锁机制重度依赖了 OS 提供了 mutex.
轻量级锁 : 加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成 . 实在搞不定了 , 再使用 mutex.  

 重量级锁基于内核的一些功能来实现,会在内核中做很多事情,比如阻塞等待;轻量级锁只涉及到少量的内核态用户态切换,加锁成本比较低.

大部分情况下,悲观锁也是重量级锁,乐观锁则是轻量级锁.但一种是对阻塞情况的预估,一种是实际锁的开销,要注意区分.

1.3 自旋锁&挂起等待锁

自旋锁:如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来.

挂起等待锁:如果获取锁失败,就会阻塞等待,放弃CPU,过了很久之后,再次尝试获取锁

 举个栗子来理解一下~

貂蝉小姐宣布和吕布复合了.她的追求者自成两派~

 滑稽A老铁就相当于一个"自旋锁",他的伪代码是这样的.

while(lock==false){ }

自旋锁是"轻量级锁"的一种实现方式,是由用户态代码实现的.

好处就是,一旦锁被其他线程释放,它就能立刻获取到锁;但是如果其他线程一直占用锁,自旋锁就会持续消耗CPU资源.

挂起等待锁往往是由内核实现的.

如果没有获取到锁,挂起等待锁也不会一直抢占CPU;但是这个线程无法第一时间获取到锁.

1.4 公平锁&非公平锁

公平锁 : 遵守 " 先来后到 ". B C 先来的 . A 释放锁的之后 , B 就能先于 C 获取到锁 .
非公平锁 : 不遵守 " 先来后到 ". B C 都有可能获取到锁 .

举个栗子~

貂蝉小姐果然不孚众望地分手了~

下面是公平锁的做法.

如果是非公平锁...

1.5 可重入锁&不可重入锁

1.5.1 可重入VS不可重入

可重入锁,允许一个线程给一个对象进行多次加锁.

不可重入锁,一个对象只能被加一次锁.

下图为可重入锁~ 

下面是不可重入锁~

 在理解这两者的概念之后,我们来详细谈一下"死锁"问题.

1.5.2 死锁的产生

死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放.

 举个接地气的例子~滑稽老铁出门的时候,不小心把车钥匙落在了家里,然后悲催地发现自己家里的钥匙在车里...

死锁产生的必要条件如下:

  1. 互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
  2. 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
  3. 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
  4. 循环等待,即存在一个等待队列.p1等待p2,p2等待p3,p3等待p1...

死锁一般是以下三种情况:

 1. 一个线程想要给一个不可重入锁加锁两次

lock (locker){//第一次加锁

    lock(locker){//第二次加锁

       ...

    }

}

 上面的代码中,假设该线程第一次加锁成功,而该线程第二次尝试加锁时发现locker对象已经被加锁,就会阻塞等待锁释放,但是第一次加锁释放的条件是代码块已被执行完.因此就产生了死锁

2. 两个线程各自拥有一把锁,但是都想要对方手里的那把

比如,两位滑稽老铁都想吃西红柿鸡蛋面,一个只有西红柿,一个只有鸡蛋~就引发了"死锁"

 3. n个线程有m把锁

最经典的例子莫过于哲学家就餐问题~

 

有五位哲学家,每天循环做两件事:思考,吃面。吃面时每人面前都有一个盘子,盘子左边和右边都有一根筷子,他们在吃面之前需要同时拿起两边的筷子,有了一双筷子就可以吃面了.很不幸,当五位哲学家同时想吃面条时,就会发生这样的事故...

 

他们谁也不肯把手里的筷子让给别人,于是只能阻塞等待...也就产生了"死锁". 

1.5.3 解决"死锁"问题

死锁的解决方法有很多种,下面介绍一种最简单的解决办法.

给锁进行编号,确定加锁顺序(比如只能按照从小到大的编号进行加锁)

 按照规则,右下角的那位哲学家只能先拿起编号为1的筷子,因此他左边的哲学家就可以拿起两只筷子吃饭了...

1.6 读写锁

多线程之间,数据的读取方之间不会产生线程安全问题,但数据的写入方互相之间以及和读者之间都需要进行互斥. 如果两种场景下都用同一个锁,就会产生极大的性能损耗。所以读写锁因此而产生.
一个线程对于数据的访问 , 主要存在两种操作 : 读数据 和 写数据 .
  1. 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
  2. 两个线程都要写一个数据, 有线程安全问题.
  3. 一个线程读另外一个线程写, 也有线程安全问题
读写锁就是把读操作和写操作区分对待 . Java 标准库提供了 ReentrantReadWriteLock , 实现了读写锁.
ReentrantReadWriteLock.ReadLock 类表示一个读锁 . 这个对象提供了 lock / unlock 方法进行
加锁解锁 .
ReentrantReadWriteLock.WriteLock 类表示一个写锁 . 这个对象也提供了 lock / unlock 方法进
行加锁解锁 .
其中 ,
  • 读加锁和读加锁之间, 不互斥.
  • 写加锁和写加锁之间, 互斥.
  • 读加锁和写加锁之间, 互斥.

 简单进行以下代码的演示~

public static void main(String[] args) {
        ReentrantReadWriteLock lock=new ReentrantReadWriteLock();
        ReentrantReadWriteLock.ReadLock readLock=lock.readLock();

        Thread thread=new Thread(()->{
            readLock.lock();
            //...执行代码逻辑
            readLock.unlock();
        });

        thread.start();
    }

读写锁特别适合于"频繁读,不频繁写"的场景中,但是有个缺点----必须手动释放,程序猿很可能会忘记解锁.

1.7 synchronized实现原理

1.7.1 synchronized的特性

结合上面的锁策略 , 我们就可以总结出 , Synchronized 具有以下特性 ( 只考虑 JDK 1.8):
  1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁.
  2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁.
  3. 实现轻量级锁的时候大概率用到的自旋锁策略
  4. 是一种不公平锁
  5. 是一种可重入锁
  6. 不是读写锁

 1.7.2 synchronized加锁过程

JVM将synchronized分成无锁,偏向锁,轻量级锁,重量级锁状态.会根据冲突情况依次升级,这个过程也被称为"锁膨胀".

 1> 偏向锁,第一个尝试加锁的线程,会进入偏向锁状态.并不是真的加锁,而是做个标记

 2> 轻量级锁,当另外一个线程也尝试对该对象加锁时,就会进入轻量级锁状态(一般是CAS实现的自旋锁)

就像电视剧上的狗血情节一样~

小红和小绿是青梅竹马,也相互喜欢.没有人追求小红之前,小绿从未官宣过小红是他的女友(此时处于偏向锁状态).小蓝对小红表白后,小绿立马发朋友圈"这是我女朋友!"(进入轻量级锁状态).

 3> 重量级锁,更多的线程尝试给该对象加锁,锁竞争激烈,就会膨胀为重量级锁(挂起等待锁)

1.8 其他的锁优化

1.8.1 锁消除

编译器+JVM 判断锁是否可消除. 如果可以, 就直接消除
在某些代码中,用到了synchronized,但是并没有涉及到多线程
此时每个 append 的调用都会涉及加锁和解锁 . 但如果只是在单线程中执行这个代码 , 那么这些加锁解锁操作是没有必要的, 白白浪费了一些资源开销 .就会触发编译器的优化.

1.8.2 锁粗化

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

粒度粗的锁会减少加锁/解锁的开销

 举个栗子~

老板安排了三个任务,你要一个一个地去汇报,还是三个做完了一起汇报?如果我是老板,我更喜欢第二种,因为你每次进来我都得准备给你泡茶...

可以看到 , synchronized 的策略是比价复杂的 , 在背后做了很多事情 , 目的为了让程序猿哪怕啥都不懂, 也不至于写出特别慢的程序.

二. CAS

2.1 什么是CAS

CAS: 全称Compare and swap,字面意思:”比较并交换,一个 CAS 涉及到以下操作:
我们假设内存中的原数据 V ,旧的预期值 A ,需要修改的新值 B
1. 比较 A V 是否相等。(比较)
2. 如果比较相等,将 B 写入 V 。(交换)
3. 返回操作是否成功。

CAS伪代码:(下面写的代码不是原子的, 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解)

boolean CAS ( address , expectValue , swapValue ) {//address为待比较值(存放在内存中),exceptValue为期望的值(存放在寄存器中),swapValue为要更改的新值(存放在寄存器中)
if ( address == expectedValue ) {
  address = swapValue ;
        return true ;
  }
    return false ;
}

当多个线程同时对某个资源进行CAS操作,只有一个线程能操作成功并返回true,其他线程只能返回false.这为我们实现"无锁编程"提供了新思路.

CAS是由硬件提供了支持才得以实现,是一个原子指令

2.2 CAS的应用

 1> 实现原子类

标准库中提供了 java.util.concurrent.atomic , 里面的类都是基于这种方式来实现的 .
典型的就是 AtomicInteger . 其中的 getAndIncrement 相当于 i++ 操作 .
AtomicInteger atomicInteger = new AtomicInteger ( 0 ); //设置初始值为0
atomicInteger . getAndIncrement (); // 相当于 i++

伪代码实现:

class AtomicInteger {

    private int value ;//内存中的值
    public int getAndIncrement () {
        int oldValue = value ;//相当于一个寄存器,存储原始数据
        while ( CAS ( value , oldValue , oldValue + 1 ) != true ) {
            oldValue = value ;
      }
        return oldValue ;
  }
}

来解释一下上面的代码,如果在单线程中,CAS操作是没有必要的,但是如果是多线程...

 

相对于synchronized来说,使用CAS操作不会引起线程阻塞等待问题,并且CAS是一条原子指令,是线程安全的.

2> 实现自旋锁

基于CAS操作的自旋锁伪代码 

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 ;
  }
}

 哪个线程调用lock方法,就会不停地循环,从而实现自旋锁

2.3 CAS的ABA问题

ABA 的问题:

假设存在两个线程 t1 t2. 有一个共享变量 num, 初始值为 A.
接下来 , 线程 t1 想使用 CAS num 值改成 Z, 那么就需要先读取 num 的值 , 记录到 oldNum 变量中 . 使用 CAS 判定当前 num 的值是否为 A, 如果为 A, 就修改成 Z.
但是 , t1 执行这两个操作之间 , t2 线程可能把 num 的值从 A 改成了 B, 又从 B 改成了 A
到这一步,线程t1无法区分当前的变量始终是A,还是经历了yigebianhu

 

可能有的同学会问,这个ABA问题会有什么影响吗?

举个栗子~

假设你手中有100块钱~打算还给小丽同学50,在你转账的时候,不小心按了两下转账,创建了两个-50的线程;就在这时,小明同学将他欠你的50还了回来

 我们期待的是,你使用的ATM机判断出来这是操作失误,只扣款50元

最终你的余额只剩了50,既然都谈到钱了,ABA问题是不是很严重!

 那么该怎么解决ABA问题呢?

给要修改的值, 引入版本号/时间戳. CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期
  • CAS 操作在读取旧值的同时, 也要读取版本号.
  • 真正修改的时候, 如果当前版本号和读到的版本号相同, 则修改数据, 并把版本号 + 1.
  • 如果当前版本号高于读到的版本号. 就操作失败(认为数据已经被修改过了).

 于是你的转账流程就变成了这样~

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

3.1 Callable接口

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

因为run方法没有返回值,就有了Callable的诞生~下面代码演示下它的使用

public static void main(String[] args) throws ExecutionException, InterruptedException {

        Callable<Integer> callable=new Callable<Integer>() {//定义Callable的内部逻辑,相当于run方法
            @Override
            public Integer call() throws Exception {
                int sum=0;
                for(int i=1;i<50;i++){//计算1+2+...+49的和
                    sum+=i;
                }
                return sum;
            }
        };
        FutureTask<Integer> futureTask=new FutureTask<>(callable);//用callable构造futureTask对象

        Thread thread=new Thread(futureTask);//用futureTask构造线程
        thread.start();//线程开始执行

        System.out.println(futureTask.get());//用futureTask接收结果
    }

 怎么用一个通俗的例子解释FutureTask类的作用捏~

就好比你去食堂排队买麻辣烫,将食材挑好之后交给食堂阿姨(Callable对象构造完成),阿姨会给你一个小票(FutureTask对象构建完成),然后阿姨开始制作(线程开始执行),最后你凭着这张小票去取你的麻辣烫(用FutureTask对象接收结果)

如果我们只关心线程的执行,重写run方法即可.

如果我们需要知道线程执行的结果,需要借助Callable类.

3.2 原子类

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

AtomicInteger 举例,常见方法有

addAndGet(int delta);   //i += delta
decrementAndGet(); //--i
getAndDecrement(); //i--
incrementAndGet(); //++i
getAndIncrement(); //i++

3.3 ReentrantLock

可重入互斥锁,与synchronized类似,都是用来保证线程安全的

ReentrantLock 的用法:

  • lock(): 加锁, 如果获取不到锁就死等.
  • trylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
  • unlock(): 解锁
public static void main(String[] args) {
        ReentrantLock lock=new ReentrantLock();
        lock.lock();
        try{
           //代码逻辑执行 
        }finally {
            lock.unlock();
        }
        
    }

 ReentrantLock与synchronized的区别:

1. synchronized使用时不需要手动释放锁,ReentrantLock需要手动释放

2. synchronized申请锁失败时会死等,ReentrantLock可以灵活调整等待时间

3. synchronized是JVM内部(大概率是C++)实现的,ReentrantLock是标准库的一个类,是基于Java实现的

4. ReentrantLock可以实现公平锁(构造方法传入true就可以创建一个公平锁)

3.4 信号量Semaphore 

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

就好像小区里的停车位一样~当前车位有100个,就表示可用资源有一百个,每当一辆车进来,就要用掉一个(称为信号量的P操作);每当一辆车出去,就要释放一个(称为信号量的V操作)

Semaphore的PV操作是原子的,可以直接在多线程环境下使用.

代码示例

public static void main(String[] args) {
        Semaphore semaphore=new Semaphore(5);//初始设置的信号量为5
        for(int i=0;i<10;i++){
            Thread thread=new Thread(()->{//创建线程
                try {
                    semaphore.acquire(2);//尝试获取2个信号量,不够时会发生阻塞等待
                    System.out.println(Thread.currentThread().getName()+"获取信号量完成");
                    Thread.sleep(1000);//休息1s后释放信号量
                    semaphore.release();
                    System.out.println(Thread.currentThread().getName()+"释放信号量完成");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            thread.start();
        }
    }

3.5 CountDownLatch

同时等待n个任务执行完成.

举个栗子~一场马拉松比赛,只有所有选手都跑到了终点,才算完成

代码示例

 public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch=new CountDownLatch(10);//一开始有十个运动员
        for(int i=0;i<10;i++){
            Thread thread=new Thread(()->{
                latch.countDown();
                try {
                    Thread.sleep(1000);//每个运动员休息1s
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            thread.start();
        }
        latch.await();//等待10个运动员全部到达
        
    }

有些同学会疑惑,这个类有什么用途嘛?

用处可大了,比如让人憎恶的"某度网盘",由于服务器那边的限制,下载速度贼慢,这时候就可以使用"多线程下载(ADM)",把一个文件拆成多份一个线程下载一部分,直到所有线程都下载完毕了才算完成.

四. 线程安全的集合类

原来的集合类 , 大部分都不是线程安全的 .
Vector, Stack, HashTable, 是线程安全的 ( 不建议用,说它安全是因为在一些关键方法上加了锁,是Java上古时期搞出来的类,未必真的线程安全 ), 其他的集合类不是线程安全的 .

4.1 多线程环境下使用ArrayList

1>  手动添加synchronized/ReentrantLock

2> 使用Cooletions.synchronizedList(new ArrayList)

该类的每一个关键操作都加了synchronized

3> 使用CopyOnWriteArrayList

CopyOnWrite 容器即写时复制的容器。
写时复制,修改的时候会先创建一个副本,然后在副本上进行修改,修改完后让副本转正.
这样修改的时候就不会对读操作造成影响.
  • 当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy复制出一个新的容器,然后新的容器里添加元素
  • 添加完元素之后,再将原容器的引用指向新容器

4.2 多线程环境使用队列

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

4.3 多线程环境使用哈希表

HashMap本身是线程不安全的

1> HashTable,在关键方法上加上了synchronized

 这相当于直接对HashTable对象本身加锁

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

如上图,当一个线程修改第一个链表,另一个线程修改第三个链表时,并不会有线程安全问题(抛开size不谈)

2> ConcurrentHashMap

 只给每个链表的对象头加锁

  1. 读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁.
  2. 加锁的方式仍然是用 synchronized, 但是不是锁整个对象, 而是 "锁桶" (用每个链表的头结点作为锁对象), 大大降低了锁冲突的概率.
  3. 充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况.

优化了扩容方式: 化整为零 .发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去. 扩容期间, 新老数组同时存在.

1. 后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小部分元素. 搬完最后一个元素再把老数组删掉.

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

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

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不 会敲代码

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值