多线程-进阶

1.常见的锁策略

(1)乐观锁vs悲观锁

预测锁冲突的概率非常高

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

举例说明:
小红和小明去向老师请教问题

  • 小红认为“老师很忙,去问问题老师不一定有空解答”,因此小红会先给老师发消息:“老师你忙吗,下午两点想找你问问题”(此时就相当于加锁操作),只有当得到肯定回复后才会真正来问问题,如果得到否定回复,那就等一段时间,下次再来确定时间,该情况是一个悲观锁
  • 小明认为"老师是比较闲的,我来问问题,老师大概率是有空解答的".因此同学B直接就来找老师.(没加锁,直接访问资源)如果老师确实比较闲,那么直接问题就解决了.如果老师这会确实很忙,那么同学B也不会打扰老师,就下次再来(虽然没加锁,但是能识别出数据访问冲突).这个是乐观锁.

这两种思路不能说谁优谁劣,而是看当前的场景是否合适.
如果当前老师确实比较忙,那么使用悲观锁的策略更合适,使用乐观锁会导致"白跑很多趟"耗费额外的资源.
如果当前老师确实比较闲,那么使用乐观锁的策略更合适,使用悲观锁会让效率比较低.

(2)重量级锁vs轻量级锁

 加锁的开销的角度

重量级锁的加锁开销比较大,要做更多的工作

轻量级锁的加锁开销比较小,要做的工作相对更少

(3)挂起等待锁vs自旋锁

挂起等待锁,就是悲观锁/重量级锁的一种典型实现.

自旋锁,则是乐观锁/轻量级锁的一种典型实现.

举例说明:

小明追求小红

小红我喜欢你,我想和你在一起(尝试对小红加锁)

小红表示我有男票了(小红表示她这把锁已经被别的线程给加了)

 

  • 此时小明可以选择等待,等待小红锁被释放,此时小明每天对小红嘘寒问暖,这种行为就称为“自旋锁”; 
    在此期间就会发生忙等,即在等待的过程中不会释放CPU资源,不停的检测锁是否被释放,一旦锁被释放就立即有机会能够获取到锁了(需要假定锁冲突概率不高的情况下,才能忙等,如果好几个线程都在竞争同一个锁,一个线程拿到锁,其他线程都在忙等...总的cpu消耗就会非常高,而且由于竞争太激烈了,就导致有些线程,要等待很久才能拿到锁)
  • 小明选择把小红拉黑,先不联系了.若干年后你从别人那里听说了,小红分手了你再去联络女神.这种行为就是"挂起等待锁"
    不联系,就相当于"让出了cpu资源"cpu 就可以用来做别的事情了,过了一段时间之后,通过其他途径,听说小红分手了.再伺机而动.
    从女神分手到你听说这个中间,可能有很长的时间跨度了(在这个时间跨度里女神是否还谈了别的男朋友?女神分手多少次?)
    挂起等待锁,也就适合“悲观锁"这样的场景
    锁竞争非常激烈,预测拿到锁的概率本身就不大.不妨把cpu让出来,充分的做其他的事情

(4)公平锁vs非公平锁

公平锁:遵守"先来后到".B比C先来的.当A释放锁的之后,B就能先于C获取到锁.

非公平锁:不遵守"先来后到".B和C都有可能获取到锁.
 

  • 操作系统内部的线程调度就可以视为是随机的.如果不做任何额外的限制,锁就是非公平锁.如果要想实现公平锁,就需要依赖额外的数据结构(比如队列),来记录线程们的先后顺序.
  • 公平锁和非公平锁没有好坏之分,关键还是看适用场景.

 (5)可重入锁vs不可重入锁

之前我们学习到synchronized是一把可重入锁,也就是说当对一个对象进行多次重复加锁时,由于编译器的优化就会只加一把锁,此时synchronized是可重入锁;
也就是说当出现死锁问题时,如果一个线程,针对一把锁,连续加锁两次,就可能出现死锁.如果把锁设定成"可重入"就可以避免死锁了.
 

可重入锁工作原理

  1. 记录当前是哪个线程持有了这把锁~~
  2. 在加锁的时候判定,当前申请锁的线程,是否就是锁的持有者线程.
  3. 计数器,记录加锁的次数.从而确定何时真正释放锁.

(6)读写锁

 读写锁就是把“加锁操作”分成两种情况:读加锁和写加锁

读写锁提供了两种加锁的api,加读锁和加写锁,解锁的api都是一样的

  • 如果多个线程同时读这个变量,没有线程安全问题,但是一个线程读/一个线程写/两个线程都写就会产生问题
  • 如果两个线程都是按照读方式加锁,此时不会产生锁冲突
  • 如果两个线程都是加写锁,此时会产生锁冲突
  • 如果一个线程读锁,一个线线程写锁也会产生锁冲突

在实际开发中对于大部分场景下读操作的频次本身就比写操作频次要高


读写锁也是Java的内置锁,Java标准库提供了ReentrantReadwriteLock类,实现了读写锁

此处就需要把unlock放到finally 中确保能够执行到.

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

(7)总结synchronized的锁策略

synchronized
1)乐观悲观,自适应2)重量轻量,自适应3)自旋挂起等待,自适应4)非公平锁
5)可重入锁6)不是读写锁 
 

(8)其它锁优化

锁升级:锁升级的过程,刚开始使用synchronized加锁,首先锁会处于偏向锁的状态(偏向锁,本质上,是在推迟加锁的时机),遇到线程之间的锁竞争就会升级到轻量级锁,进一步的统计出现的频次,达到一定程度后就会升级到“重量级锁”
synchronized加锁的时候,会经历无锁=>偏向锁=>轻量级锁=>重量级锁
偏向锁->轻量级锁:出现竞争

轻量级锁->重量级锁:竞争激烈

理解偏向锁:偏向锁不是真正的加锁(真的加锁开销可能会比较大),偏向锁只是做个标记(标记的过程非常轻量高效)

上述锁升级的过程,主要也是为了能够让synchronized这个锁很好的适应不同的场景.


对于当前JVM的实现来说,上述锁升级的过程,属于"不可逆"


锁消除(编译器的优化策略):编译器会对你写的 synchronized 代码,做出判定,判定这个地方,是否确实需要加锁.如果这里没有必要加锁的,就能够自动把synchronized给干掉.
Vector, StringBuffer .....自带synchronized


锁粗化(编译器的优化策略):锁的粒度;代码越多,就是"粒度越粗"代码越少,就是"粒度越细"
0d8f1a47871649538168df5c54a09d1e.png

2.CAS 

(1)简介

全称为Compare and swap
比较内存和cpu寄存器中的内容.如果发现相同,就进行交换(交换的是内存和另一个寄存器的内容)


一个内存的数据和两个寄存器中的数据进行操作(寄存器1和寄存器2)
比较内存和寄存器1中的值,是否相等.
如果不相等,就无事发生.
如果相等,就交换内存和寄存器2的值.


此处一般只是关心,内存交换后的内容
此处虽然叫做"交换"实际上,希望达成的效果是‘赋值'
不关心寄存器2交换后的内容
CAS伪代码

//下⾯写的代码不是原⼦的,真实的CAS是⼀个原⼦的硬件指令完成的.
boolean CAS(address, expectValue, swapValue) {
            if (&address == expectedValue) {
                &address = swapValue;
                return true;
            }
            return false;
        }


d0bf33dc238d45019d5f7431d2f3ad16.png

(2)具体使用场景 

  1. 基于CAS实现“原子类”
    int/long在进行++ --的时候,都不是原子的
    基于CAS实现的原子类对int/long等这些类型进行了直接的封装,从而可以原子的完成++ --等操作
    原子类在Java标准库中也有现成的实现
    a24a1f07b1304074a45beac989a83a45.png
    import java.util.concurrent.atomic.AtomicInteger;
    
    public class Demo4 {
        private static AtomicInteger count = new AtomicInteger(0);
        public static void main(String[] args) throws InterruptedException {
    
            Thread t1 = new Thread(() ->{
                for (int i = 0; i < 50000; i++) {
                    count.getAndIncrement();
    
                }
            });
            Thread t2 = new Thread(() ->{
                for (int i = 0; i < 50000; i++) {
                    count.getAndIncrement();
    
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println(count.get());
        }
    }

    98181f651c1340d99aa20f6d1651c006.png


     

  2. 如何通过CAS实现原子类
    针对不同的操作系统,JVM用到了不同的CAS 实现原理,简单来讲:
    1)java的CAS利用的的是unsafe这个类提供的CAS操作
    2)unsafe的CAS依赖了的是jvm 针对不同的操作系统实现的Atomic:cmpxchg;
    3)Atomic..cmpxchg的实现使用了汇编的CAS操作,并使用cpu硬件提供的lock机制保让具原子性。

    class AtomicInteger {
         private int value;
         public int getAndIncrement() {
         int oldValue = value;
         while ( CAS(value, oldValue, oldValue+1) != true) {
             oldValue = value;
         }
         return oldValue;
        }
    }

    7ac113ae57e64d618b72c8ab21da3695.png

    686c520696c4461087912512974791b0.png

(3)CAS中的ABA问题

ABA的问题:
假设存在两个线程t1和t2.有一个共享变量num,初始值为A.接下来,线程t1想使用CAS把num值改成Z,那么就需要
1)先读取num的值,记录到oldNum变量中.
2)使用CAS判定当前num的值是否为A,如果为A,就修改成Z.
但是,在t1执行这两个操作之间, t2线程可能把num的值从A改成了B,又从B改成了A
线程t1的CAS是期望num不变就修改.但是num的值已经被t2给改了.只不过又改成A了.这个时候t1究竟是否要更新num的值为Z呢?

7c323bfd34d042c3b5cadb4b65ced915.png

通过引入CAS版本号来解决此问题:

670f5017c42a40b9a3a0079262da4059.png

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

(1)Callable接口 

Callable是一个interface,相当于把线程封装了一个“返回值”,方便我们借助多线程的方式计算结果

比如说创建一个线程来实现1+2+3+4+...+1000
 

不使用Callable方法:
我们需要 额外创建一个result变量来接收,因为我们所创建的线程没有返回值

public class Demo1 {
    private static int result;
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() ->{
           int sum = 0;
            for (int i = 0; i < 1000; i++) {
                sum += i;
            }
            result = sum;
        });

        t.start();
        t.join();
        System.out.println(result);
    }
}

使用Callable版本:

  1. 创建一个匿名内部类,实现Callable接口,该接口带有泛型参数,表示返回值的类型
  2. 重写Callable的call方法,完成累加的过程,直接通过返回值返回结果
  3. 把callable实例用FuctureTask包装一下
  4. 创建线程,线程的构造方法传入FuctureTask,此时新线程就会执行FuctureTask内部的Callable的call方法完成计算,计算的结果就放到了FuctureTask对象中
  5. 在主线程中调用future.get()能够阻塞等待新线程计算完毕.并获取到FutureTask 中的结果
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class Demo1 {
    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> future = new FutureTask<>(callable);
        Thread t = new Thread(future);
        t.start();
        t.join();
        System.out.println(future.get());
    }
}

理解Callable:

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

理解FutureTask: 

类似于吃饭的号码牌,号码牌就是作为取餐的依据,通过号码牌来确定是否完成

 (2)可重入互斥锁ReentrantLock

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

ReentrantLock的用法:

  • lock():加锁,如果获取不到锁就死等.
  • trylock(超时时间):加锁,如果获取不到锁,等待一定的时间之后就放弃加锁.
  • unlock():解锁

实际开发中,大多数情况下使用synchronized即可

 

ReentrantLock和synchronized的差别

  1. synchronized 属于是关键字(底层是通过JVM的c++代码实现的)
    ReentrantLock 则是标准库提供的类,通过Java代码来实现的.
  2. synchronized通过代码块控制加锁解锁
    ReentrantLock通过调用lock unlock方法来完成. unlock可能会遗漏(把unlock放到 finally 中)
  3. ReentrantLock提供了tryLock这样的加锁风格.
    前面介绍的加锁,都是发现锁别别人占用了,就阻塞等待.
    tryLock在加锁失败的时候,不会阻塞,而是直接返回,通过返回值来反馈是加锁成功还是失败.
  4. ReentrantLock 还提供了公平锁的实现.
    默认是非公平的.可以在构造方法中,传入参数,设定成公平的
  5. ReentrantLock 还提供了功能更强的"等待通知机制"基于Condition类.能力要比wait notify更强一些.

代码举例:
count++十万次

import java.util.concurrent.locks.ReentrantLock;

public class Demo4 {
    private static int count;
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock(true);
        Thread t1 = new Thread(() ->{
            try{
                lock.lock();
                for (int i = 0; i < 50000; i++) {
                    count++;
                }
            }finally {
                lock.unlock();
            }
        });
        Thread t2 = new Thread(() ->{
            try{
                lock.lock();
                for (int i = 0; i < 50000; i++) {
                    count++;
                }
            }finally {
                lock.unlock();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

 (3)信号量Semaphore

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

理解信号量:

可以把信号量想象成是停车场的展示牌:当前有车位100个.表示有100个可用资源.
当有车开进去的时候,就相当于申请一个可用资源,可用车位就-1(这个称为信号量的P操作)

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

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

acquire对应的就是P操作

release对应的就是V操作


操作系统本身提供了信号量实现,JVM把操作系统的信号量封装了一下

通过Semaphore申请一个可用资源,我们就可以达到类似开锁解锁的状态

public class Demo2 {
    private static int count = 0;
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock reentrantLock = new ReentrantLock();
        Semaphore semaphore = new Semaphore(1);
        Thread t1 = new Thread(() ->{
            for (int i = 0; i < 50000; i++) {
                try {
                    semaphore.acquire();
                    count++;
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        Thread t2 = new Thread(() ->{
            for (int i = 0; i < 50000; i++) {
                try {
                    semaphore.acquire();
                    count++;
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(count);
    }
}

(4)CountDownLatch

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

实际的使用场景:
多线程下载(搭配线程池使用)

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class Demo5 {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(4);//四个线程
        CountDownLatch countDownLatch = new CountDownLatch(20);//拆分出来任务的个数
        for (int i = 0; i < 20; i++) {
            int id = i;
            executorService.submit(()->{
                System.out.println("下载任务" + id + "开始执行");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("下载任务" + id + "结束执行");
                countDownLatch.countDown();
            });

        }
        countDownLatch.await();
        System.out.println("所有任务完成");
    }
}

34b28c6a5044438090982c48079a370e.png

  • 构造CountDownLatch 实例,初始化10表示有10个任务需要完成.
  • 每个任务执行完毕,都调用latch.countDown() .在CountDownLatch 内部的计数器同时自减
  • 主线程中使用latch.await();阻塞等待所有任务执行完毕.相当于计数器为0了.

4.相关面试题 

  1. 线程同步的方式有哪些?
    synchronized,ReentrantLock,Semaphore等都可以用于线程同步
  2. 为什么有了synchronized还需要juc下的lock?
    以juc的ReentrantLock为例,
    ①synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活
    ②synchronized 在申请锁失败时,会死等,ReentrantLock可以通过 trylock 的方式等待一段时间就放弃.
    ③synchronized 是非公平锁, ReentrantLock 默认是非公平锁,可以通过构造方法传入一个 true 开启公平锁模式
    ④synchronized 是通过 Object 的 wait/notify 实现等待-唤醒,每次唤醒的是一个随机等待的线程
    ReentrantLock搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指定的线程
  3. AtomicInteger的实现原理是什么?
    基于CAS机制的伪代码
    class AtomicInteger {
         private int value;
         public int getAndIncrement() {
             int oldValue = value;
             while ( CAS(value, oldValue, oldValue+1) != true) {
                 oldValue = value;
         }
             return oldValue;
         }
    }
    
  4. 信号量是什么?用在过那些场景?
    信号量用来表示“可用资源的个数”,本质上就是一个计数器
    使用信号量可以实现“共享锁”,比如某个资源允许3个线程同时使用,那么就可以使用P操作作为加锁,V操作作为解锁,前三个线程的P操作都能顺利返回,后续线程在进行P操作就会阻塞等待,直到前面的线程执行了V操作
  5. 解释一下 ThreadPoolExecutor构造方法的参数的含义
    1816245305a1423d8817a326a4891597.png


5.线程安全的集合类

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

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

6.多线程环境下使用ArrayList

  1. 自己使用同步机制(synchronized或ReentrantLock)
  2. Collections.synchronizedList(new ArrayList);
    synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.synchronizedList的关键操作上都带有 synchronized
  3. 使用CopyOnWriteArrayList
    CopyOnWriteArrayList容器即写时复制的容器

    当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,

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

    这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
    所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
    优点:在读多写少的场景下,性能很高,不需要加锁竞争
    缺点:①占用内存较多②新写的数据不能被第一时间读取到

7.多线程环境下使用队列

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

8.多线程环境下使用哈希表

 Hash本身不是线程安全的

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

  • Hashtable
  • ConccurrentHashMap
  1. Hashtable
     只是简单的把关键方法加上了synchronized关键字
    public sychronized V put(K key,V value)
    
    public sychronized V get(Object key)

    相当于直接针对Hashtable对象本身进行加锁

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

  2. ConcurrentHashMap
    ①读操作没有加锁(但是使用了 volatile 保证从内存读取结果),只对写操作进行加锁,加锁的方式仍然是用 synchronized,但是不是锁整个对象,而是"锁桶"(用每个链表的头结点作为锁对象),大大降低了锁冲突的概率
    ②充分利用 CAS 特性.比如 size 属性通过 CAS 来更新.避免出现重量级锁的情况
    优化了扩容方式: 化整为零
        发现需要扩容的线程,只需要创建一个新的数组,同时只搬几个元素过去
        扩容期间,新老数组同时存在

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

        搬完最后一个元素再把老数组删掉这个期间,插入只往新数组加这个期间,查找需要同       时查新数组和老数组
    4b5132a9230e40518badf5f5694879e6.png

9.相关面试题 

3ee9840c6d994e3fb6a94ff37373b205.png 

 

  • 11
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值