《JavaEE初阶》多线程进阶

《JavaEE初阶》多线程进阶

常见锁策略

锁策略是什么?

即加锁的时候应该怎么加才比较合适.

锁策略与编程语言Java关系不大,其他编程语言也有"锁策略"

乐观锁与悲观锁

乐观锁: 预测接下来锁的冲突概率较小,做一类操作.

悲观锁: 预测接下来锁的冲突概率较大, 做一类操作.

举个例子:

线程B需要访问线程A,线程B可以做两种选择:

  1. 先提前告知线程A某个时间段需要去访问.如果线程A 在这个时间比较空闲,在得到线程A的许可,那么到达某一时间.在操作系统的调度下,线程B再去访问线程A ,这是悲观锁思想

  2. 线程B直接去访问线程A,如果线程A较忙,则下一次再继续来访问,如果线程A较闲,则可以直接访问到. 这是乐观锁思想.

这两种锁没有优劣之分,只要符合需求场景即可:

  • 乐观锁往往是纯用户态执行的,这也意味着效率比较高,但是也会带走CPU资源

  • 悲观锁是需要内核执行的,对当前线程进行挂起等待,这也意味着效率差.

synchronized是一个自适应锁,既是悲观锁也是乐观锁.

synchronized初始是一个乐观锁,当发现锁冲突的概率较大时,会转化为悲观锁.

普通互斥锁与读写锁

synchronized就是普通的互斥锁,即两个锁操作之间会发生竞争.

读写锁就相当于将加锁操作细化,加锁分为了"加读锁"和"加写锁".在执行加锁操作时需要额外表明读写意图,读者之间并不互斥,而写者则要求与任何人互斥

可分为三种情况:

  • 线程A加写锁,线程B加写锁, 那会导致互斥.

  • 线程A加读锁,线程B加读锁,那不会导致互斥,因为两个线程读取一份数据,是线程安全的.

  • 线程A加写锁,线程B加读锁, 那会导致互斥.

Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.

ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁.

ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁.

轻量级锁与重量级锁

轻量级锁: 锁的开销比较小,做的工作比较少

重量级锁: 锁的开销比较大,做的工作比较多

对于悲观锁往往是重量级锁,而乐观锁往往是轻量级锁,但这并不是一定的.乐观锁一样可以是重量级锁,而悲观锁也一样可以是轻量级锁.

重量级锁由于依赖了操作系统提供的锁,所以就容易产生阻塞等待了.

轻量级锁采用的策略是尽量地避免使用操作系统提供的锁,尽量在用户态下完成功能,尽量避免内核态和用户态的切换,尽量地避免线程挂起等待.

synchronized 是自适应锁,既是轻量级锁也是重量级锁.synchronized会根据锁冲突的情况来自适应的转换.

自旋锁与挂起等待锁

自旋锁是轻量级锁的具体实现,自旋锁是轻量级锁也是乐观锁.

自旋锁在遇到锁冲突时不会使线程挂起等待,而是会立即尝试获取这个锁.

  1. 当锁被释放时,可以第一时间获取锁

  2. 但是如果锁一直不释放,那么会一直消耗CPU资源

挂起等待锁是重量级锁的具体实现,挂起等待锁是重量级锁也是悲观锁.

挂起等待锁在遇到锁冲突时,会直接挂起等待,等待操作系统的调度.

  1. 当锁被释放时,不能第一时间获取到锁

  2. 在锁被其他线程占用的时候,会放弃CPU资源.

synchronized在作为轻量级锁的时候,内部实现为自旋锁,在作为重量级锁的时候,内部实现为挂起等待锁.

公平锁和非公平锁

以三个线程为例:

当线程A拿到锁后,线程B尝试获取锁,获取失败,挂起等待.线程C尝试获取锁,获取失败,挂起等待.

当线程A 释放锁的时候.

如果是公平锁:

那么会符合"先来后到"的规则,由线程B先获取到锁,等线程B释放锁后,线程C才能获取.

如果是非公平锁:

那么会产生"机会均等"的竞争,线程B和线程C会产生竞争,都有可能获取到锁.

synchronized是非公平锁.

对于操作系统的挂起等待锁,也是非公平锁.

如果想要实现公平锁,需要借助额外的数据结构来记录线程的先后顺序来实现.

可重入锁和不可重入锁

当我们在同一个线程中加同一把锁多次:

public static void func(){
       synchronized(locker){
           synchronized(locker){

           }
       }

}

在这种情况下,在没有可重入锁的情况下:即不可重入锁:
会导致: 当线程第一次获取到锁时, 线程想要第二次获取到锁,但是线程第一次获取到锁了,所以会导致线程第二次尝试获取锁的时候,会阻塞等待.

也就是: 第一次释放锁依赖于第二次获取到锁,第二次获取到锁依赖于第一次释放锁.

直接导致了死锁的问题出现.

为了解决这个问题,引入了可重入锁的概念:
对于一个线程,可以同时对同一个锁加多次.可重入锁会在内部记录这个锁是哪个线程获取的,如果发现当前加锁的线程和持有锁的线程是同一个,则不会阻塞等待,而是直接获取到锁.同时在内部使用计数器来记录当前是第几次加锁了,利用计数器来控制啥时候释放锁.

对于synchronized而言,它是可重入锁.

CAS

什么是CAS

CAS是操作系统/硬件 提供给JVM 的一种更轻量的实现原子操作的机制.

CAS 是 CPU提供的一条特殊的指令 “compare and swap” , 是原子操作.

  • compare 是比较, 比较寄存器中的值与内存的值是否相等.

  • 如果相等 , 则将寄存器中的值和另一个值进行交换.

  • 如果不相等,则不进行操作.

即:
假设内存中的原数据A,旧的预期值B,需要修改的新值C。

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

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

  3. 返回操作是否成功。

伪代码:

boolean CAS(address, expectValue, swapValue) {
 if (&address == expectedValue) {
   &address = swapValue;
        return true;
   }
    return false;
}

CAS的应用场景:

  1. 利用CAS实现原子类.

    在Java标准库java.util.concurrent.atomic包中, 利用CAS实现了许多原子类的操作,典型的就是 利用 CAS 实现 “i++” 操作

    public static void main(String[] args) {
           AtomicInteger i = new AtomicInteger();
           Thread t1 = new Thread(()->{
               for(int m = 0;m < 1000000;m++){
    
                   i.getAndIncrement();
               }
           });
           Thread t2 = new Thread(()->{
               for(int m = 0;m < 1000000;m++){
                   // 相当于i++
                   i.getAndIncrement();
               }
           });
           t1.start();
           t2.start();
           try {
               t1.join();
               t2.join();
           } catch (InterruptedException e) {
               e.printStackTrace();
           }
    
           System.out.println(i);
    
       }
    

    我们可以看到运行结果是2000000, 是线程安全的.

    实现伪代码:

    class AtomicInteger {
       private int value;
       public int getAndIncrement() {
           int oldValue = value;
           while ( CAS(value, oldValue, oldValue+1) != true) {
               oldValue = value;
          }
           return oldValue;
      }
    }
    
  2. 利用CAS实现原子类.

    伪代码:

    public class SpinLock {
        private Thread owner = null;
        public void lock(){ 
            while(!CAS(this.owner, null, Thread.currentThread())){
           }
       }
        public void unlock (){
            this.owner = null;
       }
    }
    

    当owner为空时, 才可以CAS成功, 如果 owner 不为空, 则认为当前锁已经被其他线程占用,则需要继续循环( 实现 自旋)

CAS 的 ABA 问题

什么是CAS 的 ABA 问题

在CAS 中, 无法区分一个数据是否发生过转变, 例如数据x, CAS 无法判断 数据x 是否发生过

x -> y -> x 的情况.

这就可能引发bug,举个例子:
小明现在有存款 1000 块钱, 小明 想要取款 200 ,但是小明 不小心按了 取款机两下,创建了两个线程.

在正常的情况下:

线程1 和 线程2 同时读到了1000 .

线程1 将1000 与 1000 进行比较,发现 相等, 进行扣款,同时线程2阻塞等待.

线程2 将1000 与 扣款成功后的 800 进行比较 发现不相等,则不进行扣费.

在异常的情况下,

当线程1 扣款成功后,线程2 还没来的及执行的时候,小明的朋友给小明存钱200.这个时候线程2无法判断1000 与 1000 -> 800 -> 1000 的情况,就会认为两者相等 那么进行了又一次扣款.

这就引发了bug.

解决方案:

引入" 版本号 ":

我们在进行CAS操作时, 可以给 变量 加入一个 版本号, 我们在进行一次操作时, 都会让版本号只能++或者–或者其他保证在时间上不会出现相等的情况算法, 这样每一次变量只要发生了改变, 变量在时间上可能会发生相等的情况,但是版本号在时间上不会发生相等的情况

synchronized的工作原理:

synchronized使用的锁策略:

  1. 既是乐观锁也是悲观锁.(自适应)

  2. 既是轻量级锁也是重量级锁.(自适应)

  3. 轻量级锁基于自旋锁实现, 重量级锁基于挂起等待锁实现.

  4. 不是读写锁,是普通的互斥锁

  5. 是公平锁

  6. 是可重入锁

synchronized的加锁过程:

  1. 无锁

    即没有出现加锁操作

  2. 偏向锁(并没有实际进行加锁操作)

    线程只是对锁进行了标记,标记了这个锁目前是属于这个线程的, 在没有其他线程来竞争锁的情况下,并不会实际的进行加锁. 当有其他线程来竞争这个锁了,才会实际地进行加锁.(类似于"懒汉模式",只有当需要的时候才加锁)

  3. 轻量级锁

    产生了锁竞争,不激烈会一直保持轻量级锁.

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

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

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

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

    • 但是这里并不会无限次自旋,如果自旋过多次没有获取到锁,这会膨胀转化为重量级锁.

  4. 重量级锁

    锁的竞争比较激烈.

    此处的重量级锁就是指用到内核提供的 mutex .

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

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

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

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

    • 经历了一系列的等待时间, 这个锁被其他线程释放了, 操作系统也想起了(本质还是调度算法)这个挂起的线程, 于是唤醒这个线程, 尝试重新获取锁.

其他锁优化:

锁消除:

编译器会只能判定这个代码是否需要加锁:

StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

append 方法中内部是带synchronized的,但是如果上述代码在一个线程中执行,那么就会认为本身就无线程安全问题,那么JVM 就会将锁消除.

这个操作在大部分情况下都是不会触发的.能触发的情况比较少.

锁粗化:

锁的粒度:表示synchronized中的代码范围大小,范围越大,则认为锁越粗,范围越小,则认为锁越细

锁的粒度细了,就可以实现更好的线程并发,但是也会带来"加锁次数过多"的问题.

for(int i = 0;i < 1000;i++){
    synchronized(locker){
     i++;
    }
}
synchronized(locker){
    for(int i = 0;i < 1000;i++){
        i++;
    }
}

JUC 中 相关的类

Callable接口:

学会使用Callable接口:

public class demo21 {
    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++;
                }
                return sum;
            }
        };
        //套上一层,目的是为了获取上面的结果
        FutureTask<Integer> fask = new FutureTask<Integer>(callable);
        //将fask传入线程
        Thread t = new Thread(fask);
        t.start();
        //get方法会在线程执行完毕计算出结果并返回 才会执行
        System.out.println(fask.get());
    }
}

在这里,callable相当于一个带有返回值的任务,我们在写好这个callable之后,需要给他套上一层FutureTask 这样才能传入线程并获取结果. 对于返回的结果我们可以通过 get() 方法获取.

ReentrantLock

这里主要是可重入锁.

为什么synchronized已经是可重入锁,还要使用ReetrantLock?

  1. synchronized只是一个关键字,以代码块对代码进行加锁解锁.

    ReentrantLock这是一个类,使用lock 加锁, unlocker解锁.

  2. synchronized固定为"非公平锁"

    ReentrantLock 提供了一个 “公平锁版本”,可以自由切换"公平锁" 和"非公平锁"

    import java.util.concurrent.locks.ReentrantLock;
    
    public class demo21 {
        public static void main(String[] args) {
            //true  锁为公平锁
            //false 锁为非公平锁
            ReentrantLock lock = new ReentrantLock(true);
            try{
                lock.lock();
                lock.trylock();
                //具体代码逻辑.但是这里可能会导致异常,这样就执行不到后面的unlock,所以为了保险,我们需要在finall中执行unlock.
            }finally {
                lock.unlock();
            }
        }
    }
    
  3. synchronized如果加锁失败,则会阻塞等待.

    ReentrantLock 提供了两种选择:

    lock: 如果加锁失败,则阻塞等待.

    unlock: 如果加锁失败,这不会阻塞等待.直接往下执行并且返回false.

  4. ReentrantLock 提供了更强大的 等待唤醒机制.

    synchronized的wait()和notify()只能随机唤醒其中的一个线程.而ReentrantLock则可以通过Condition类来指定唤醒某一个线程或者随机唤醒.

如何选择这两把锁:

  1. 锁冲突较小,使用synchronized.

  2. 锁冲突较大,使用ReentrantLock,ReentrantLock 的 tryLock可以更好地控制加锁的行为,不至于死等.

  3. 需要使用公平锁就要用ReentrantLock

原子类:

基于CAS实现的原子类:

  • AtomicBoolean

  • AtomicInteger

  • AtomicIntegerArray

  • AtomicLong

  • AtomicReference

  • AtomicStampedReference

以AtomicInteger为例的方法:

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

线程池:

这里重点介绍:ThreadPollExecutor类

在这里插入图片描述

以最复杂的版本来认识参数:

在这里插入图片描述

  • corePoolSize: 核心线程数

  • maximumPoolSize : 最大线程数

    在线程池中,分为两类线程:核心线程和临时线程,核心线程无聊是否空闲还是忙碌,都会存在,而临时线程只有在忙碌的时候创建,空闲的时候则会销毁.

  • keepAliveTime : 允许临时线程空闲的最长时间,

  • TimeUnit unit : keepAliveTime的时间单位.

  • BlockingQueue workqueue :

    线程池中虽然内置了任务队列,但是我们也可以将自定义的任务队列传入线程池.

  • ThreadFactory threadFactory :

    参与具体的线程创建工作.

  • RejectedExecutionHandler handler:

    拒绝策略,当线程池的任务队列满了之后,该如何操作:

    • 超过负荷,直接抛出异常,停止工作.

    • 交给添加任务的调用者处理(即把任务重新扔回去)

    • 丢掉任务队列中最老的任务

    • 丢掉任务队列中最新的任务

对于线程池中的线程数如何确定:

不同的场景需要的线程数是不确定的,如果回答固定的数字,那一定是错误的,我们无法明确我们创建的线程池需要多少个线程,但是我们可以通过恰当的方法来设置线程池的线程数.

进行压测:
针对当前的程序进行性能测试,分别设置不同的线程池的数目,分别进行测试,记录测试过程中的程序的响应时间,CPU占用,内存占用等等…根据实际情况来确定线程池中的核心数量.

对于CPU密集场景: 线程数最多也只能是CPU核心数,就算超过也无意义

对于IO 密集场景: 线程数可以超过CPU核心数,因为 IO 不吃 CPU资源

但是在实际开发中, 一般是CPU与IO并存, 具体得看两者的比例才能确定线程池的核心数.

信号量semaphore

是一个描述可用资源数量的计数器:

申请一个资源: 信号量就-=1 ,为 P操作 (原子操作)

释放一个资源: 信号量就+=1, 为 V操作 (原子操作)

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

semaphore可以直接控制多线程线程安全的控制,可以认为是一把更加广义的锁,当信号量的总数为0-1时,则可以认为是一把普通的锁了.

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

public static void main(String[] args) throws InterruptedException {
        Semaphore semaphore = new Semaphore(3);
        //申请一个资源:
        semaphore.acquire();
        //释放一个资源:
        semaphore.release();

    }

CountDownLatch

同时等待N个线程结束

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

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

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

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(10);
        for(int i = 0;i < 10;i++){
            Thread t = new Thread(()->{
                try {
                    Thread.sleep(3000);
                    System.out.println("执行结束");
                    //相当于撞线
                    latch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
            t.start();
        }
        latch.await();
        System.out.println("全部执行完毕");

    }

线程安全的集合类:

多线程下使用Arraylist

  1. Collections.synchronizedList(new ArrayList);

    synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.

    synchronizedList 的关键操作上都带有 synchronized

  2. CopyOnWriteArrayList:

    如果出现了修改操作,就立即对顺序表进行拷贝,当新线程修改后再用副本替换.成本开销较大.

多线程下使用队列:

  1. ArrayBlockingQueue

    基于数组实现的阻塞队列

  2. LinkedBlockingQueue

    基于链表实现的阻塞队列

  3. PriorityBlockingQueue

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

ConcurrentHashMap详解:

在之前我们已经了解到了 Hashmap 是线程不安全的 , HashTable 是线程安全的.

但是我们之前为什么不推荐使用HashTable?

这是因为HashTable保证线程安全的方式是直接对整个哈希表进行synchronized,这样只能保证多线程下修改不同的哈希桶下的值会线程安全,如果多线程下修改同一个哈希桶下的值,就无法保证线程安全了.

在这里插入图片描述

而ConcurrentHashMap对此进行了重大改进:

将锁的粒度细化,在每一个哈希桶上都加锁.

在这里插入图片描述

ConcurrentHashMap的优化特点:
  1. 将锁的粒度细化,每一个哈希桶都加锁,大大地减少了锁冲突的概率.

  2. 对读没加锁,只对写加锁

  3. 在维护size 的时候 采用 CAS 特性

  4. 针对扩容场景进行优化,每一次基本操作只扩容一点,逐渐完成整个扩容操作.在扩容的时候,旧表和新表同时存在,查询的时候旧表和新表一起查询,每一次新增操作,就往新表上新增,直到所有的元素都搬运完,才完成整个扩容.

HashMap,HashTable和ConcurrentHashMap的区别:
  1. HashMap是线程不安全的,而HashTable 和 ConcurrentHashMap 是线程安全的

  2. HashTable内部使用的是一把大锁,而ConcurrentHashMap将锁的粒度细化,为每个哈希桶都加了锁,极大地降低了锁冲突的概率.

  3. 扩展讲 : ConcurrentHashMap 的主要优化特点

  4. HashMap key允许为null, 其他两个不允许.

死锁问题:

  • 1个线程1把锁的情况:

    线程A对同一把锁加锁多次,如果该锁是不可重入锁 这会导致死锁问题.

  • 2个线程2把锁:

    线程1获取锁A

    线程2获取锁B

    线程1尝试获取锁B

    线程2尝试获取锁A

    这样线程1无法获取线程2未释放的锁B,线程2无法获取线程1未释放的锁A.造成死锁

  • M个线程 N 把锁:

    哲学家问题:
    哲学家就餐问题是在计算机科学中的一个经典问题,用来演示在并行计算中多线程同步(Synchronization)时产生的问题。
    有五个哲学家,他们共用一张圆桌,分别坐在五张椅子上。在圆桌上有五个碗和五支筷子,平时一个哲学家进行思考,饥饿时便试图取用其左、右最靠近他的筷子,只有在他拿到两支筷子时才能进餐。进餐完毕,放下筷子又继续思考。
    当哲学家都同时拿起左边的筷子时,则会发现所有人手中都只有一把筷子,谁都在等别人吃完,但是谁也不能吃.

在这里插入图片描述

如果把哲学家比做线程 则认为线程死锁.

解决方案:

对锁进行编号:1 2 3 4…

约定当一个线程需要获取多把锁时,明确获取锁的顺序是从小到大.

在这里插入图片描述

对于解决死锁问题,还有个更复杂的"银行家算法".(了解)

死锁的四个必备条件
  1. 互斥使用: 线程A获取到锁但是不释放,其他线程获取不到

  2. 不可抢占: 线程A获取到锁,其他线程只能阻塞等待,无法直接抢占锁

  3. 请求和保持: 当线程A已经获取到锁A时,如果再去请求获取别的资源, 会一直保持"已经获取到锁A"的状态,直到释放锁.

  4. 循环等待: 存在一个循环队列,使得线程之间同时只能获取一部分锁,导致死锁.

死锁总结:

死锁是线程在获取到锁后,没有及时释放,导致其他线程获取这把锁时获取失败,只能阻塞等待,导致整个程序僵住.

产生死锁的三个典型场景.

死锁的必备条件.

从循环等待切入,理解如何破解哲学家就餐死锁问题.

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

小连~

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

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

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

打赏作者

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

抵扣说明:

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

余额充值