多线程(三)锁策略、synchronized原理、Lock体系、线程安全的集合类、死锁

目录

一、常见的锁策略

1、乐观锁和悲观锁

2、读写锁

3、轻量级锁和重量级锁

4、自旋锁

5、公平锁和非公平锁

6、可重入锁和不可重入锁

二、CAS(Compare and swap)

1、CAS

2、CAS的ABA问题

三、synchronizd原理

1、基本特点

2、synchronized原理

2、JVM对锁的升级(加锁的过程)

 (1)偏向锁

(2)轻量级锁

(3)重量级锁

3、其它优化操作

(1)锁消除

(2)锁粗化

四、JUC的常见类

1、ReenTrantLock

synchronized和ReentrantLock区别

2、原子类(原子性并发包)

3、信号量Semaphore

4、CountDownLatch

五、Callable接口

六、线程安全的集合类

1、List(了解)

2、Map

(1)hashtable

(2)ConcurrenthashMap

七、死锁


一、常见的锁策略

1、乐观锁和悲观锁

  • 乐观锁:以乐观的心态看待线程冲突(总觉得没有线程会同时操作共享变量)。所以每次都不加锁(程序层面),直接操作变量。(乐观锁在Java中的一种实现CAS)
  • 悲观锁:以悲观的心态看待线程冲突(总觉得其他线程会同时操作共享变量)。所以每次都会加锁操作共享变量。

适用场景:

  • 乐观锁适用于基本不存在线程冲突的程序;
  • 悲观锁适用于大部分情况下存在线程冲突的程序。

2、读写锁

多线程之间,数据的读操作之间不会产生线程安全问题,但数据的写入方之间以及和读方之间都需要进行互斥。如果两种操作都使用同一种锁,就会产生很大的性能消耗。读写锁因此产生。

读写锁就是将读和写区分对待,Java标准库中提供了ReentrantReadWriteLock类,实现了读写锁:

  • ReentrantReadWriteLock.ReadLock类表示一个读锁,提供lock/unlock方法进行加锁/释放锁;
  • ReentrantReadWriteLock.WriteLock类表示一个写锁,也提供了lock/unlock方法进行加锁/释放锁。

其中,读读不互斥,读写/写写之间互斥。

读写锁非常适用于“频繁读,不频繁写”的场景。

3、轻量级锁和重量级锁

锁的核心特性“原子性”:

  • cpu提供了“原子操作指令”;
  • 操作系统基于cpu的原子指令,实现了mutex互斥锁;
  • JVM基于操作系统实现的互斥锁,实现了Synchronized和ReentrantLock等关键字和类。

重量级锁:加锁机制重度依赖os提供的mutex。

  • 大量的内核态用户态切换;
  • 很容易引发线程的调度。

轻量级锁:加锁机制尽可能不使用mutex,尽量在用户态代码完成。实在不行,再使用mutex。

  • 少量的内核态用户态切换;
  • 不太容易引发线程调度。

4、自旋锁

线程在强锁失败后进入阻塞状态,放弃cpu,需要很久才能被再次调度。大部分情况下,虽然当前强锁失败,但过不了多久锁就会被释放。自旋锁:如果获取锁失败,立即再次尝试获取锁,知道获取到锁为止。一旦锁被其他线程释放,就能第一时间获取到锁。

自旋锁一般搭配乐观锁实现。

语法如下:(因为可以使用while循环来实现)

自旋锁是一种典型的轻量级锁的实现方法:

  • 优点:没有放弃cpu,不存在线程阻塞和调度,一旦锁被释放,就能第一时间获取到锁;
  • 缺点:如果锁被其他线程持有的时间比较长,就会持续地耗费cpu资源(挂起等待的时候不消耗cpu)

5、公平锁和非公平锁

  • 公平锁:遵循“先来后到”的规则;
  • 非公平锁:不遵循“先来后到”的规则。
    【1】优点:效率更高
    【2】缺点:可能出现线程饥饿(某些线程长时间不能执行)的现象

【注】

  • 操作系统内部的线程调度就是随机的。如果不做任何的额外限制,锁就是非公平锁。要想实现公平锁,就需要依赖额外的数据结构,来记录线程的先后顺序。
  • 公平锁和非公平锁没有好坏之分。

6、可重入锁和不可重入锁

可重入锁:允许同一个线程多次获取同一把锁。

比如一个递归函数中有加锁操作,递归过程中这个锁会阻塞自己吗?如果不会,就是可重入锁。(可重入锁也叫递归锁)。

Java中只要以Reentrant开头命名的锁都是可重入锁。JDK提供的Lock类、以及synchronized都是可重入锁。

二、CAS(Compare and swap)

1、CAS

CAS:乐观锁(加锁的一种思想,程序看是没锁的)的一种实现

 CAS的实现原理:

  • Java中的CAS的实现利用的是unsafe类提供的cas方法;
  • 以上cas又是基于操作系统+cpu提供的机制来实现。

2、CAS的ABA问题

ABA问题:读和写操作比较主存中变量的值时,值没有变,但实际已经被修改过(A->B->A)

解决方案:

给要修改的值,引入版本号。在CAS比较数据当前值和旧值时,也要比较版本号是否相同。

  • CAS操作在读取值时,同时也要读取版本号;
  • 真正修改时:
    【1】如果当前版本号和读到的版本号相同,则修改数据,并把版本号+1;
    【2】如果当前版本号高于读到的版本号,操作失败(说明数据已经被修改过)。

三、synchronizd原理

1、基本特点

  • 初始使用乐观锁,当发现锁竞争比较频繁时,自动切换成悲观锁;
  • 开始是轻量级锁,如果锁被持有的时间较长,就转换为重量级锁;
  • 是一种不公平锁;
  • 是一种可重入锁;
  • 实现轻量级锁时大概率用到的是自旋锁策略;
  • 不是读写锁。

2、synchronized原理

通过对象头加锁的方式实现线程安全,申请同一个对象锁时,多个线程间同步互斥

对象中有一个对象头,其中就有一个状态的字段:无锁、偏向锁、轻量级锁、重量级锁。synchronized申请锁成功,就是后三种锁状态之一。

synchronized编译为字节码文件后,里面存在一个头对象的monitor机制(监视器)。

2、JVM对锁的升级(加锁的过程)

 (1)偏向锁

第一次尝试加锁的线程,优先进入偏向锁状态。

偏向锁的本质上是“延迟加锁”,能不加锁就不加锁,避免不必要的加锁开销。

偏向锁的原理:

偏向锁不是真的加锁,只是给对象头中做一个标记,记录这个锁属于哪个线程。如果后续没有其他线程来竞争该锁,就不需要进行加锁操作;如果后续有其他线程来竞争该锁(之前做了标记,很容易识别当前申请锁的线程是不hi是之前标记的线程),取消原来的偏向锁状态,进入轻量级锁状态。

(2)轻量级锁

随着其它线程进入竞争,偏向锁状态解除,进入轻量级锁状态(自适应的自旋锁+CAS)

此处的自旋锁通过CAS来实现:

  • 通过CAS检查并更新一块内存;
  • 如果更新成功,则认为加锁成功;
  • 如果更新失败,则认为锁被占用,继续自旋式等待(不放弃CPU)
    【自旋操作是一直让cpu空转,比较消耗cpu资源,因此此处的自旋不会一直持续下去,到达一定的时间/次数后,就不再自旋,这就是“自适应”】

(3)重量级锁

如果竞争进一步激烈,自选不能快速获取到锁状态,则变为重量级锁(指的是内核提供的mutex)。

  • 执行加锁操作,先进入内核态;
  • 在内核态判断当前锁是否已经被占用;
  • 如果没有被占用,则加锁成功,切换回用户态;
  • 如果被占用,则加锁失败,线程进入等待状态,等待被再次唤醒;
  • 当锁被释放后,这个线程被唤醒,重新获取锁

3、其它优化操作

(1)锁消除

编译器+jvm判断锁是否可以消除。如果可以,直接消除。

如以下代码:

此时每个append都会涉及到枷锁和释放锁。但如果只是在单线程中执行这些代码,那么这些加锁解锁操作是没有必要的,白白浪费一些资源。 

(2)锁粗化

共享变量可能被其他线程持有,但不停的执行加锁/释放锁的操作。jvm就可以优化为第一次执行时加锁,全部执行完后再释放锁。

 对于以上代码,会在第一次拼接之前进行加锁,直到所有的都拼接完后再释放锁。

四、JUC的常见类

JUC:java.util.concurrent包,Java的并发包。提供了很多多线程需要的且效率很高的API。

1、ReenTrantLock

可重入锁,属于独占锁。通过构造函数提供公平锁和非公平锁。

ReentrantLock用法:

  • lock():加锁,获取不到就死等;
  • trylock(time):加锁,如果获取不到锁,等待一段时间后放弃加锁;
  • unlock():解锁

 synchronized内部有解锁机制,但是lock需要手动解锁,需要注意如果使用lock加锁的线程在执行中出现异常,可能导致锁无法释放,造成死锁,需要使用finally来执行锁的释放。

synchronized和ReentrantLock区别

  • synchronized是一个关键字,而ReentrantLock是标准库中的类;
  • synchronized使用时不需要手动释放锁,而ReentrantLock需要手动释放锁(就会存在一个问题,如果加锁的代码出现异常,线程会异常终止,此时就需要使用finally来保证锁的释放)
  • ReentrantLock提供了公平锁和非公平锁,而synchronized只是非公平锁;
  • 线程竞争激烈时,使用ReentrantLock更高效
    【1】释放锁之后,synchronized是唤醒所有的线程来竞争(设计线程状态转换,存在性能消耗)
    【2】ReentrantLock只是唤醒一个线程(使用aqs来管理线程)
  • synchronized申请锁失败时,会死等;而ReentrantLock可以通过trylock的方式等待一段时间后就放弃。

2、原子类(原子性并发包)

内部使用的是CAS+自旋锁,所以性能比加锁实现i++要高得多。常见原子性并发包如下:

  • AtomicBoolean
  • AtomicInteger
  • AtomicIntegerArray
  • AtomicLong
  • AtomicLongArray

以AtomicInteger为例:

3、信号量Semaphore

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

  • acquire方法表示申请资源(P操作)
  • release方法表示释放资源(V操作)
  • PV操作的加减计数器都是原子的,可以在多线程环境下直接使用

 使用场景:

  1. 有限资源的并发执行:

  2. 多个线程并发执行,需要全部执行完,某个线程再执行后续的代码:

4、CountDownLatch

同时等待N个线程结束。

五、Callable接口

提供一个带返回值的任务描述方法,结合Futrue相关的API,就可以获取线程的执行结果。

Callable:

  • Callable和Runnable相对,都是描述一个任务。不同的是,Callable描述的是带有返回值的任务,Runnable描述的是不带返回值的任务;
  • Callable通常搭配FutrueTask来使用,FutrueTask来保存Callable返回的结果。因为Callable往往是在另一个线程中执行(不确定执行完成的时间);
  • FutrueTask就负责等待这个结果出来

例:使用Callable创建线程实现1~1000求和。

  • 创建匿名内部类实现Callable接口。Callable带有泛型参数,表示返回值的类型;
  • 重写Callable的call方法,完成累加,返回计算结果;
  • 将Callable实例使用FutureTask包装;
  • 创建线程,构造方法传入FutureTask。此时新线程会执行FutureTask内部的Callable的call方法,完成计算,结果放在FutrueTask的对象中;
  • 在主线程中调用futuretask.get()能够阻塞等待新线程计算完毕,并获取到计算结果。

【代码如下】

public class CreateThread {
    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 = 1; i <= 1000; i++) {
                    sum+=i;
                }
                return  sum;
            }
        };
        FutureTask<Integer> futureTask=new FutureTask<>(callable);
        Thread t=new Thread(futureTask);
        t.start();
        int ret=futureTask.get();
        System.out.println(ret);
    }
}

六、线程安全的集合类

1、List(了解)

(1)Collections.synchronizedList(new ArrayList):

返回一个同步的List,内部使用synchronized保证线程安全,效率不高。

(2)CopyOnWriteArray:写时复制容器

  • 进行写操作时,复制一个新的容器,往新的容器中添加元素,此时旧的容得容器仍然可以进行读操作。
  • 添加完元素后,再将旧的容器的引用指向新的容器。

优点:读读/读写并发并行执行(在读多写少的情况下,性能很高,不需要进行锁竞争)。但占用的内存较多(空间换时间)。

2、Map

(1)hashtable

只是简单的将关键的方法加synchronized关键字,相当于给hashtable对象本身加锁。

  • 多线程访问同一个hashtable对象直接造成锁冲突;
  • 一旦扩容,该线程完成整个扩容过程,涉及到大量的元素拷贝,效率很低。

(2)ConcurrenthashMap

1.8的实现:数组+链表+红黑树

  • 读操作不加锁(使用volatile保证)。只对写操作进行加锁操作,使用synchronized加锁,只是锁每次操作对应的“桶”(每个链表的头节点作为锁对象),大大降低锁的冲突概率。
  • 扩容方式:化整为零
    【1】需要扩容时,创建新的数组,同时只搬几个元素过去,
    【2】扩容期间新老数组同时存在
    【3】扩容时,读操作需要同时查找新老数组
    【4】插入操作只往新数组中查
    【5】后续每个操作ConcurrentHashMap的线程,都会参与搬家操作,每个操作搬一小部分
    【6】搬完最后一个元素老数组删除

1.7的实现:数组+链表

  • 加锁方式:分段锁

  • hash冲突严重时,链表会比较长,查询时间复杂度很高

七、死锁

死锁是一种情形:多个线程同时被阻塞,他们中的一个或多个都在等待某个资源被释放。由于线程被无限期的阻塞,因此程序不可能正常停止。

【死锁产生的四个必要条件】

  • 互斥使用:当资源被一个线程占有时,另一个线程不能访问;
  • 不可抢占:资源请求者不可从资源占有者手中夺取资源,资源只能有资源占有者自动释放;
  • 请求和保持:资源请求者在请求别的资源时仍然保持对原有资源的占有;
  • 循环等待:存在一个循环队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。形成了一个等待环路。

上述四个条件都成立时,形成死锁。死锁的情况下打破上述任意一个条件,便可让死锁消失。(最容易破坏的是“循环等待”)

解决死锁问题:

破坏这个循环等待的条件:比如大家按照一定的顺序来申请锁。

猜你喜欢

转载自blog.csdn.net/weixin_54342360/article/details/124703673
今日推荐