多线程

126 阅读15分钟

一、volatile

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

  • 禁止进行指令重排序。

  • 保证不同线程对这个变量进行操作时的可见性。即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。

那么原理是什么呢?

在《深入理解JAVA虚拟机》中有如下描述:

“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”。

lock前缀指令实际上相当于一个内存屏障 ,内存屏障会提供3个功能:

  • 它确保指令重排序时,不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

  • 它会强制将对缓存的修改操作立即写入主存;

  • 如果是写操作,它会导致其他CPU中对应的缓存行无效。

    问题:第2步很好理解,第3步如何做到呢?

    因为其他CPU的缓存遵守了缓存一致性协议

    缓存一致性协议(MESI协议):

    在早期的CPU中,是通过在总线加LOCK锁的方式实现的,但这种方式开销太大,所以Intel开发了缓存一致性协议,也就是MESI协议。

    设计思路:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,那么他会发出信号通知其他CPU将该变量的缓存行设置为无效状态。当其他CPU使用这个变量时,首先会去嗅探是否有对该变量更改的信号,当发现这个变量的缓存行已经无效时,会从新从内存中读取这个变量。

    这就保证了一个volatile在并发编程中,其值在内部缓存、系统内存和其他处理器的缓存中是保持一致的,是可见的。

volatile的不足

使用volatile关键字,可以保证可见性,但是不能保证原子操作。

比如两个线程要同时对 count 进行 count++ 操作,此时查看编译后的字节码可以看到,这个过程分为3个步骤:

1、从内存读取count
2、count + 1
3、将count的新值写回

关键点来了,volatile只能保证线程在读取这一步拿到的值是最新的,但当该线程执行到下面几个步骤时,这期间可能就有其它线程把count的值修改了,最终导致旧值把真正的新值覆盖。

所以,并发编程中,只靠volatile修饰共享变量是不可靠的,最终还是要通过对关键方法加锁来保证线程安全。

二、锁

多线程访问共享资源的时候,避免不了资源竞争而导致数据错乱的问题,所以我们通常为了解决这一问题,都会在访问共享资源之前加锁。

最常用的就是互斥锁,当然还有很多种不同的锁,比如自旋锁、读写锁、乐观锁等,不同种类的锁自然适用于不同的场景。

如果选择了错误的锁,那么在一些高并发的场景下,可能会降低系统的性能,这样用户体验就会非常差了。

所以,为了选择合适的锁,我们不仅需要清楚知道加锁的成本开销有多大,还需要分析业务场景中访问的共享资源的方式,再来还要考虑并发访问共享资源时的冲突概率。

对症下药,才能减少锁对高并发性能的影响。

2.1、互斥锁和自旋锁

最底层的两种就是会「互斥锁和自旋锁」,有很多高级的锁都是基于它们实现的,你可以认为它们是各种锁的地基,所以我们必须清楚它俩之间的区别和应用。

  • 互斥锁加锁失败后,线程会释放 CPU ,给其他线程。

    1、互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞

    2、对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。会有两次线程上下文切换的成本。当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。

    3、线程的上下文切换的是什么?当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。

    4、上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长。所以,如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。

  • 自旋锁加锁失败后,线程会忙等待处于循环状态,直到它拿到锁。

    自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。

总结:如果你能确定被锁住的代码执行时间很短,甚至少于互斥锁上下文切换的时间,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。

2.2、读写锁

读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。

所以,读写锁适用于能明确区分读操作和写操作的场景

  • 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁。多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率。
  • 如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁。

2.3、悲观锁和乐观锁

前面提到的互斥锁、自旋锁、读写锁,都属于悲观锁。

悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁

那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。

乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作

可见,乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现乐观锁全程并没有加锁,所以它也叫无锁编程

这里举一个场景例子:在线文档。

我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。

那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。

怎么样才算发生冲突?

这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交改动,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。

服务端要怎么验证是否冲突了呢?通常方案如下:

  • 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;
  • 当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前修改后的版本号进行比较,如果版本号一致则修改成功,否则提交失败。

实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。

总结:乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。

2.4、可重入锁

看下面这段代码就明白了:

class MyClass {
    public synchronized void method1() {
        method2();
    }
     
    public synchronized void method2() {

    }
}

上述代码中的两个方法method1和method2都用synchronized修饰了,假如某一时刻,线程A执行到了method1,此时线程A获取了这个对象的锁,而由于method2也是synchronized方法,假如synchronized不具备可重入性,此时线程A需要重新申请锁。但是这就会造成一个问题,因为线程A已经持有了该对象的锁,而又在申请获取该对象的锁,这样就会线程A一直等待永远不会获取到的锁。

而由于synchronized和Lock都具备可重入性,所以不会发生上述现象。

2.5、公平锁和非公平锁

公平锁:以请求锁的顺序来获取锁。比如同时有多个线程在等待一个锁,当这个锁被释放时,等待时间最久的线程(最先请求的线程)会获得该所,这种就是公平锁。

非公平锁:无法保证锁的获取是按照请求锁的顺序进行的。

ReentrantLock有两个构造器,一个是无参构造,一个是传入fair参数的。

fair代表锁的公平策略,true:表示构造一个公平锁;false:表示构造一个非公平锁(默认)。

三、死锁

死锁案例

public class DeadLock2 {
    static Object objA = new Object();
    static Object objB = new Object();

    public static class MyThread implements Runnable {
        public void run() {
            System.out.println(Thread.currentThread().getName() + ",run prepare");
            synchronized (objA) {// obj锁
                System.out.println(Thread.currentThread().getName() + ",拿到objA锁");
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (objB) {
                    System.out.println(Thread.currentThread().getName() + ",拿到objA锁");
               }
            }
        }
    }

    public static class MyThread2 implements Runnable {
        public void run() {
            System.out.println(Thread.currentThread().getName() + "run prepare");
            synchronized (objB) {// obj锁
                System.out.println(Thread.currentThread().getName() + ",拿到 objB锁");
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (objA) {
                    System.out.println(Thread.currentThread().getName() + ",拿到 objA锁");
                }
            }
        }
    }

    public static void main(String[] args) {
       new Thread(new MyThread()).start();
       new Thread(new MyThread2()).start();
    }
}

MyThread拿到objA锁,MyThread2拿到objB锁,等到MyThread想去拿objB锁时,objB锁被MyThread2拿着,MyThread2想去拿objA锁时,objA锁被MyThread拿着,两边各不相让,造成了死锁。

死锁产生的条件

  • 互斥:某共享资源一次只允许一个线程占有。
  • 占有且等待:一个线程本身占有资源(一种或多种),同时还有资源未得到满足,则不会释放已有的资源。(白话:我的是我的,我得不到你的,你也别想得到我的)
  • 不可抢占:其他线程不能抢占已被别的线程占有的资源。(白话:我的是我的,谁也别想拿去)
  • 循环等待:线程A等待线程B的资源,线程B等待线程A的资源,这就是循环等待。(白话:A说:你不给我,我也不给你;B说:你不给我,我也不给你)

死锁怎么解决

只要破坏一个死锁产生的条件就不会发生死锁。

设计思想:若重新设计一把互斥锁去解决这个问题,怎么搞呢?

  • 能响应中断 使用synchronized持有 锁X 后,若尝试获取 锁Y 失败,则线程进入阻塞,一旦死锁,就再无机会唤醒阻塞线程。但若阻塞态的线程能够响应中断信号,即当给阻塞线程发送中断信号时,能唤醒它,那它就有机会释放曾经持有的 锁X。

  • 非阻塞地获取锁 如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁

  • 支持超时(在非阻塞地获取锁上加个时间) 若线程在一段时间内,都没有获取到锁,不是进入阻塞态,而是返回一个错误,则该线程也有机会释放曾经持有的锁

这就是Lock。

四、synchronized

4.1、作用

  • 原子性:确保线程互斥的访问同步代码;

  • 可见性:保证共享变量的修改能够及时可见,其实是通过JVM内存模型中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值” 来保证的;

  • 有序性:有效解 决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”。

从语法上讲,Synchronized可以把任何一个非null对象作为"锁",在HotSpot JVM实现中,锁有个专门的名字:对象监视器(Object Monitor)。

4.2、使用

4.3、原理

数据同步需要依赖锁,那锁的同步又依赖谁?

  • synchronized给出的答案是在软件层面依赖JVM。

  • Lock给出的答案是在硬件层面依赖特殊的CPU指令。

当一个线程访问同步代码块时,首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,那么它是如何来实现这个机制的呢?我们先看一段简单的代码:

package com.paddx.test.concurrent;

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("Method 1 start");
        }
    }
}

查看反编译后结果:

反编译结果

  • monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
  1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
  2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
  3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
  • monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁

通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因

再来看一下同步方法:

package com.paddx.test.concurrent;

public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

查看反编译后结果:

反编译结果

从编译的结果来看,方法的同步并没有通过指令 monitorentermonitorexit 来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的

当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。