多线程之内功精修

一、常见的锁策略

锁策略并不仅仅局限于Java,任何关于“锁”的问题,都有可能涉及到锁策略,虽然锁策略往往是给设计锁的人来作为参考的,但是我们了解之后使用起synchronized来也会更加得心应手

(一)悲观锁和乐观锁

1.悲观锁
  在悲观锁看来,它里面的数据会时常发生改变,预期锁冲突的概率很高,因此一个线程拿到锁后,其他线程必须等到锁释放之后才能竞争并访问代码块中的代码。因此我们说悲观锁做的工作多,付出成本高,比较低效

2.乐观锁

  在乐观锁看来,它里面的数据不会经常改变,预期锁冲突的概率很低,因此它允许多个线程同时对数据进行变动,那这样说,锁不就失去作用了吗?并不是,它会对数据是否产生冲突进行检测,如果发现冲突了,就返回错误信息,让用户决定该怎么做。因此我们说,乐观锁做的工作更少,付出成本更低,比较高效

(二)读写锁和互斥锁

  读写锁把对共享资源的访问者划分成读者和写者,读者只对共享资源进行读访问,写者则需要对共享资源进行写操作。相对于读写锁来说,互斥锁就是普通的锁,只有加锁和解锁操作。

我们需要注意的是:

  • 两个线程都只是读一个数据,此时并没有线程安全问题,直接并发的读取即可
  • 两个线程都要写一个数据,就有线程安全问题
  • 一个线程读另外一个线程写,也有线程安全问题

  因此读写锁就有了下面的特性:一次只有一个线程可以占有写模式的读写锁,但是可以有多个线程同时占有读模式的读写锁

具体操作就是

  • 读加锁和读加锁之间, 不互斥
  • 写加锁和写加锁之间, 互斥
  • 读加锁和写加锁之间, 互斥

读锁:ReentrantReadWriteLock.ReadLock
写锁:ReentrantReadWriteLock.WriteLock

(三)重量级锁和轻量级锁

  前提说明,互斥锁(Mutex Lock)位于操作系统底层,如果频繁的调用互斥锁进行加锁解锁,那么就会导致线程在用户态和内核态之间频繁切换,带来较大的性能损耗

1.重量级锁
加锁机制严重依赖操作系统提供的互斥锁(Mutex   Lock)

  • 频繁用户态内核态切换
  • 容易引发线程调度

以上两个操作成本都比较高

2.轻量级锁
加锁机制尽量不使用mutex,而是尽量在用户态代码完成,实在不行,再使用mutex

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

  通常悲观锁都是重量级锁,乐观锁都是轻量级锁,但是不绝对

(四)挂起等待锁和自旋锁

  1. 挂起等待锁,往往就是通过内核态的一些机制来实现的,往往较重(重量级锁的一种典型实现)
  2. 自旋锁,往往就是通过用户态代码来实现的,往往较轻(轻量级锁的一种典型实现)

  关于以上两种锁,我们可以笼统的认为挂起等待锁就是互斥锁,而自旋锁和互斥锁都是为了解决对某项资源的互斥使用。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,线程进入睡眠状态。但是自旋锁不会引起线程睡眠,而是让线程一直循环看锁是否释放。到时候锁释放后抢占互斥锁的被唤醒,抢占自旋锁的线程直接去抢占自旋锁不需要被唤醒。
  因此才说挂起等待锁是重量级锁,因为它睡眠唤醒的操作就要涉及到内核态了,但是自旋锁只是在用户态让线程不断的观察锁的状态

(五)公平锁和非公平锁

比如说线程A先来,一段时间之后B来了,C在B之后来,不久A把锁释放
对于公平锁和非公平锁而言:

  1. 公平锁遵循“先来后到”,获得锁的顺序就是A->B->C
  2. 非公平锁不遵循“先来后到”,A把锁释放后,B和C等机会进行抢占,谁先抢到算谁的

  对于操作系统来说,在相同优先级的情况下,本身就是随机调度的,因此基于操作系统实现的 mutex 互斥锁,就属于非公平锁,而如果想实现公平锁,反而代价会大一点,比如说起码要有个队列来排先来后到

(六)可重入锁和不可重入锁

  可重入锁的概念与解释我们在多线程基础的时候介绍synchronized时,就已经详细解释了,总的来说,可重入锁就是允许一个线程连续多次获取同一把锁(可以嵌套着使用同一把锁),不可重入锁则表示一个线程只能获取一把锁一次,想要再次获取,必须先释放才能再获取

  Java里只要以 Reentrant 开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括 synchronized 关键字锁都是可重入的。而 Linux 系统提供的 mutex 是不可重入锁

二、CAS

(一)概念

CAS:全称Compare and swap
一个CAS操作涉及到以下操作:

  1. 比较 A 与 V 是否相等(比较)
  2. 如果相等,将B写入V(交换)
  3. 返回操作是否成功

硬件予以了支持,软件层面才能做到

伪代码说明

//如果A和V表示的是同一块空间的值,那么就可以表示原空间的值不变,就把新值赋值给旧值
while(flg == true) {
    if(A == V) {
        V = B;
        flg = false;
    }
}

  最重要的是,CPU提供了一个单独的CAS指令,换句话说,CAS是原子性的操作,上述伪代码的操作是线程不安全的,但是一条指令是线程安全的,因此CAS最大的意义,就是为我们写线程安全的代码提供了一个新的思路和方向

(二)基于 CAS 实现的操作

基于CAS实现原子类

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

public static void main(String[] args) throws InterruptedException {
    //AtomicInteger就是一个原子类
    AtomicInteger num = new AtomicInteger();
    Thread t1 = new Thread(new Runnable() {
        @Override
        public void run() {
            for (int i = 0; i < 500; i++) {
              //getAndIncrement方法相当于num++操作
               num.getAndIncrement();
            }
        }
    });
    Thread t2 = new Thread(new Runnable() {
        @Override
        public void run() {
            for (int i = 0; i < 500; i++) {
                num.getAndIncrement();
            }
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(num);
}

(三)CAS的 ABA 问题

1.概念

  CAS的操作中,第一步是比较,第二步是交换,如果有一个地址,在获取到这个地址的数值之前,数值是1,在比较前数值被其他线程修改,变成0,又变成了1,此时进行比较,1还是1,但已经不是原来的那个1,但是CAS还把他当作原来的那个1来对待,就可能引发一些问题

2.ABA问题举例说明

  假如小明很喜欢小美,并且小明知道小美一直没有男朋友,小美跟小明说,如果三个月之后,我还是单身,我们就在一起。小明答应了,然后这三个月小明和小美再没见过,在这期间,小美换了五个男朋友,等到三个月一到,小美恢复单身,他俩在一起了,虽然小美看起来没什么变化,但是其实她已经不是原来的她了,假如小明有洁癖,他自己是初恋,他也只想跟对方是初恋的情况下谈恋爱,现在暂时没事,但是说不准什么时候小明就知道了,这颗雷就会引爆

3.解决方案

给要修改的值,引入版本号,每次值改变之后,版本号都会加1,在CAS比较数据当前值和旧值的同时,也要比较版本号是否相同

  • CAS操作在读取旧值的同时,也要读取版本号
  • 到了要比较修改的时候
  • 如果当前版本号和读到的版本号相同,就修改数据,并把版本号 + 1
  • 如果当前版本号高于读到的版本号,就操作失败(版本号不同,认为数据已经被修改过了)

三、Synchronized 中的优化机制(jdk1.8版本机制)

(一)Synchronized锁的类型

  1. 即是一个乐观锁,也是一个悲观锁
  2. 不是读写锁
  3. 既是一个轻量级锁,也是一个重量级锁
  4. 轻量级锁的部分是自旋锁的操作,重量级锁的部分是挂起等待锁的操作
  5. 是非公平锁
  6. 是可重入锁

(二)Synchronized的优化手段

1.锁膨胀/锁升级

  JVM将 Synchronized 锁分为无锁,偏向锁,轻量级锁,重量级锁状态,根据情况,依次升级

  1. 无锁,即没有加锁的情况
  2. 偏向锁,有一个线程来加锁,但synchronized不会真的加锁,只是做了一个标记
  3. 轻量级锁(自旋锁),当存在其他线程对这个锁进行竞争时,Synchronized就会转为自旋锁
  4. 重量级锁,当线程很多,竞争很激烈的时候,此时再使用自旋锁对CPU资源浪费就太大了,因此换成重量级锁,即让等待的线程直接挂起等待

2.锁粗化

  粗化对应的就是细化,这里的粗和细是指“锁的粒度

  1. 细指的是一个锁里面代码段少,此时加锁解锁的操作就会更加频繁,但是优点是并发程度高了
  2. 粗是指一个锁里面代码段较多,加锁解锁的频率降低,缺点就是并发程度不高

由此看来,锁粗和锁细各有优缺点
  实际开发中,使用细度锁,是期望释放锁的时候其他线程能够使用锁,但是实际上可能并没有其他线程来抢占这个锁,这种情况JVM就会自动把锁粗化,避免频繁申请释放锁,提高运行效率。但是如果锁比较粗,一般不会进行这种锁优化

3.锁消除

  当一段代码不必加锁(比如单线程环境)而我们给他加了锁,那么就会触发“锁消除”,即把该锁取消掉,是否该取消,由编译器 + JVM来判断

四、JUC(java.util.concurrent)

JUC是处理并发操作的包

(一)Callable接口

1.用法

Callable是一个interface,和Runnable类似,也描述一个任务,但是具有返回值,也是一种创建线程的方式

代码示例(计算1~1000的和并返回):

public static void main(String[] args) {
    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> task = new FutureTask<>(callable);
    Thread t = new Thread(task);
    t.start();
    try {
        System.out.println(task.get());
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    } catch (ExecutionException e) {
        throw new RuntimeException(e);
    }
}

2. Callable的理解

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

3. FutureTask的理解

  类似一个转接器,FutureTask实现了Runnable接口,并且内部有一个Callable属性,既然Callable本身无法作为参数传递给Thread,那么就借助FutureTask,实现run方法,在run方法内部执行Callable中的call方法,并保存返回值,最后通过get()来得到返回值
  由于call执行也需要时间,因此get可能会发生阻塞,等待结果的产生

(二)ReentrantLock

  显而易见,它是一个可重入锁,另外,他还是一个互斥锁,,和synchronized类似,都是为了实现互斥,并且能保证线程安全

1.用法

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

代码示例:

public static void main(String[] args) {
    ReentrantLock lock = new ReentrantLock();
    lock.lock();
    try {

    }finally {
        lock.unlock();
    }
}

  如上图,可以看出ReentrantLock的加锁解锁操作是分开的,这样其实不如合起来,因为很可能代码写的多了会忘记释放锁,因此需要将锁的释放放在finally,这样代码出现问题时,也可以将锁释放,但是写起来比较麻烦

2.ReentrantLock 和 synchronized 的区别

  1. synchronized是一个关键字(逻辑由C++实现),ReentrantLock是一个标准库的类(逻辑由Java代码实现)
  2. synchronized不需要手动释放锁,执行完代码块,锁自动释放,ReentrantLock必须手动释放锁
  3. synchronized如果竞争锁失败,就会阻塞等待,但是ReentrantLock除了阻塞等待操作,还可以指定最长等待时间,失败了直接返回,不必一直死等
  4. synchronized是一个非公平锁,ReentrantLock提供了非公平和公平锁两个选择,如果想要使用公平锁,那么在创建锁时需要传递参数true
  5. synchronized的等待唤醒机制是wait notify,功能比较有限,每次唤醒只是唤醒随机的一个线程;ReentrantLock的等待唤醒机制,是Condition类,功能更加丰富,可以实现指定线程的唤醒操作,这点为第四点的公平锁实现了前提条件

(三)信号量-Semaphore

  信号量,用来表示 “可用资源的个数”。本质上就是一个计数器
  申请一个可用资源,可用资源 - 1(信号量的 P 操作),释放一个可用资源,可用资源 + 1,(信号量的 V 操作),如果计数器的值已经为0,还尝试申请资源,就会阻塞等待,直到有其他线程释放资源
  Semaphore的PV操作都是原子的,可以在多线程环境下直接使用
  信号量可以看作是广义锁,我们理解的锁,是被一个线程获取到之后其他线程就无法获取了,这样的锁又可以被称为“二元信号量”,可用资源就一个,计数器的取值,非0即1

代码示例:

public static void main(String[] args) throws InterruptedException {
    //表示可用资源数为4
    Semaphore semaphore = new Semaphore(4);
    //申请一个资源,可以传参申请多个
    semaphore.acquire();
    System.out.println("申请成功");
    semaphore.acquire();
    System.out.println("申请成功");
    semaphore.acquire();
    System.out.println("申请成功");
    semaphore.acquire();
    System.out.println("申请成功");
    semaphore.acquire();
    System.out.println("申请成功");
    //释放资源
    semaphore.release();
}

  上述代码中,当申请成功四次之后,可用资源数为0,因此就会阻塞,也就不会执行到下面的代码了

(四)CountDownLatch

CountDownLatch是一个线程同步类,能同时等待 N 个任务执行结束

代码示例:

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);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(Thread.currentThread().getName() + "执行结束");
            //这步操作才是告诉latch,一个线程结束
            latch.countDown();
        });
        t.start();
    }
    //latch中存在类似一个计数器的属性,一个信号发过来,计数器值就减 1
    //如果数目没到之前参数的个数,那么就阻塞等待
    latch.await();
    System.out.println("全部执行完毕");
}

运行结果:
CountDownLatch练习

五、线程安全的集合类

  我们之前学习数据结构时学习到的集合类,大部分都是线程不安全的,有些是线程安全的,但是不建议使用,,因为加锁太多,执行效率太低。比如:

Vector, Stakc,HashTable

(一)多线程环境使用 ArrayList

  1. 自己使用同步机制(synchronized 或者 ReentrantLock)

  2. Collections.synchronizedList(new ArrayList);
    synchronizedList是标准库提供的一个基于synchronized进行线程同步的List
    synchronizedList的关键操作都带有synchronized

  3. 使用 CopyOnWriteArrayList
    CopyOnWrite容器即写时复制的容器。
    当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
    这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
    所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器
    优点
     在读多写少的场景下,性能很高,不用加锁
    缺点

    1. 占用内存较多,不适合修改数据量很大的内容,一般可用于更新配置数据
    2. 新写的内存不能被第一时间读取到

(二)多线程环境下使用队列

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

(三)多线程环境使用哈希表(重点

  HashMap不是线程安全的,在多线程环境下使用哈希表可以使用:

  • HashTable
  • ConcurrentHashMap

1. HashTable

HashTable只是简单把关键方法,比如get,put加上了synchronized,这相当于直接针对HashTable对象本身加锁,这样的操作会造成极大的锁冲突

  1. 多个线程访问同一个HashTable会冲突
  2. size属性也是通过HashTable来控制同步
  3. 一旦触发扩容,就由当前线程完成整个扩容过程,这个过程会涉及到大量的元素拷贝,效率极低

  我们知道,Java处理哈希冲突的操作是数组 + 链表,因此对于每一个链表来说,它们相互之间是独立的,如果两个操作针对的是不同的链表,那么完全可以对不同链表分开加锁,既保证安全,又大大降低了锁冲突的效率

2.ConcurrentHashMap

ConcurrentHashMap相比于Hashtable做出了一系列的改进和优化,以Java1.8为例

  1. 把锁加到每个链表的头节点上(锁桶)
  2. 只是针对写操作加锁了,读操作没有加锁只使用
  3. 更广泛的使用CAS,比如维护size
  4. 如果扩容,每次操作只搬运一点,通过多次操作完成整个搬运过程,这样做的话,就需要同时维护一个新的HashMap和旧的HashMap,查找的时候既查新的,也查旧的,插入的时候只插新的,直到搬运完毕,再销毁旧的
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

求索1024

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

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

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

打赏作者

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

抵扣说明:

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

余额充值