架构师系列-并发编程(五)-线程的三大特性

2.2 线程的三大特性

线程的三大特性:

  • 可见性:Visibility
  • 有序性:Ordering
  • 原子性:Atomicity

而这三个特性往往是并发编程bug的源头,而并发编程的bug往往也都是疑难杂症,如果想要快速定位这些问题的根源,我们就得理解这些问题的本质,而这些又跟我们底层操作系统和硬件设备有关系。

三大特性的根源

我们知道CPU,内存,IO设备是一台计算机的核心组成部分,三者虽然都在不断的迭代,不断的变快,但在这个大家都在发展的历史长河中一直都存在一个主要矛盾:三者之间的速度存在着量级上的差异,我们都知道CPU远快于内存,内存远远快于IO设备。

为了合理利用 CPU ,平衡这三者的速度差异,计算机体系结构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU 添加高速缓存,来平衡与内存的速度差异;
  • 操作系统支持多进程、多线程,以分时复用 CPU,进而均衡 CPU 与 I/O 设备的速度差异;
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理地利用。

而并发编程很多bug的根源也都在这里。

2.2.1、CPU缓存导致可见性问题

如下图所示:

 

对于共享变量i,首先要将其从内存中读到CPU中,然后对其进行相关操作,如果线程A对其进行了修改操作,线程B能够立马看到线程A操作的结果,我们将其称之为线程之间的可见性

在单核CPU架构下, 所有的线程都是在一颗 CPU 上执行,因为所有线程都是操作同一个 CPU 的缓存,一个线程对缓存的写,对另外一个线程来说一定是可见的。

但是在多核CPU时代,每颗 CPU 都有自己的缓存,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存,如下图:

 线程A对CPU1缓存中的数据进行了修改,线程B不能立马可见,因为线程B操作的是CPU2的缓存,这就带来了多个线程操作共享变量时的数据不一致问题,具体场景见如下代码

// 多线程共享变量
private static  boolean running =  true;

private static void t1() throws InterruptedException {
    new Thread(()->{
        while (running) {

        }
        System.out.println("thread exit");
    }).start();

    TimeUnit.SECONDS.sleep(5);
    running = false;
}
2.2.2、线程切换导致原子性问题

早期计算机是单进程的,后来引入了多进程,这样即便是在单核CPU上,从宏观上我们依然可以并发执行多个程序,当然在微观上是操作系统给每个进程分配一个时间片,多个进程分时复用CPU,好处就是不会因为某个进程等待IO而浪费CPU资源,当然带来的问题是要进行CPU的调度,早期操作系统确实是以进程为单位来调度CPU的,不同进程间是不共享内存空间的,所以进程要做任务切换就要切换内存映射地址,如下:

这种切换属于一种重量级的切换,现代的操作系统都基于更轻量的线程来调度,而一个进程创建的所有线程,都是共享一个内存空间的,所以线程切换的成本相对就很低了,并且线程切换的时机大都是在时间片结束的时候。如下:

这里需要注意的是我们现在一般都使用的是高级编程语言,而高级编程语言中的一句代码可能在底层对应着多条CPU的指令,拿java中如下代码来说:

// 假设i的初始化值为0 
i+=1

 

在底层至少需要三条CPU指令:

1:把变量i的值从内存load到CPU寄存器

2:在CPU中执行+1的操作

3:将结果store到内存(当然也可能只存到CPU缓存而没刷新到内存)

虽然操作系统能保证每条指令执行的时候是具备原子性的,但是操作系统进行线程切换,可以发生在任意一条CPU指令执行完成之后(注意是CPU指令级别)。那这对高级编程语言来说多线程并发时就会造成原子性问题,如下图所示:

 我们把一个或者多个操作在 CPU 执行的过程中不被中断的特性称为原子性。CPU 能保证的原子操作是 CPU 指令级别的,而不是高级语言的操作符,因此,很多时候我们需要在高级语言层面保证操作的原子性。具体场景见如下代码

static class AtomicRunnable implements Runnable {
    int i = 0;

    @Override
    public void run() {
        i+=1;
        System.out.println("---"+i);
    }
}

public static void main(String[] args) {
    AtomicRunnable runnable = new AtomicRunnable();
    for (int i=0;i<100000;i++) {
        new Thread(runnable).start();
    }
}
2.2.3、性能优化导致有序性问题

所谓有序性,很容易想到就是程序按照代码的先后顺序来执行。但是有时候为了提高性能,在不影响最终结果的前提下会优化代码/指令的执行顺序,这里会有这两种情况的出现:

编译优化

编译器能够自由的以优化的名义去改变指令顺序,如下:

x=5;
y=6;
z=x+y;

优化后可能变为

y=6;
x=5;
z=x+y;

所谓顺序,指的是你可以用顺序的方式推演程序的执行,但是程序指令的执行不一定是完全顺序的。编译器保证结果一定 等于 顺序方式推演的结果

处理器乱序执行

为了使得处理器内部的运算单元尽量被充分利用,处理器可能会对输入指令进行乱序执行(Out-Of-Order Execution)优化,也就是说处理器可能会次序颠倒的执行指令。数据可能在寄存器,处理器缓冲区和主内存中以不同的次序移动,而不是按照程序指定的顺序,而这个是我们看不到也感知不到的,并且出现了问题也很难重现。

乱序执行技术是处理器为提高运算速度而做出违背代码原有顺序的优化。

  • 单核环境下,处理器保证做出的优化不会导致执行结果远离预期目标,但在多核环境下却并非如此。
  • 多核环境下, 如果存在一个核的计算任务依赖另一个核的计算任务的中间结果,而且对相关数据读写没做任何防护措施,那么其顺序性并不能靠代码的先后顺序来保证。

现举几个例子验证程序会出现编译优化/乱序执行的现象:

1、对象的创建有一个中间状态

public class C01_NewObject {

    int m = 8;

    public static void main(String[] args) {
        C01_NewObject c = new C01_NewObject();
    }
}

对应的字节码如下

0 new #3 <com/learning/ts_03_ordering/C01_NewObject>
3 dup
4 invokespecial #4 <com/learning/ts_03_ordering/C01_NewObject.<init> : ()V>
7 astore_1
8 return

我们能看到在代码中一句简单的new对象,其实对应着多条字节码。

整个过程大致可分为这么几步:

  • 分配一块内存
  • 在内存空间上初始化对象
  • 将内存空间的地址赋值给引用变量

要注意的是,在分配完内存还未初始化时,对象的实例变量是有一个初始默认值的,比如int就是0。初始化完成之后实例变量才会赋真正的值。

有了这些知识铺垫之后,我们可以来看在java中一个经典的案例就是利用双重锁校验创建单例对象,比如如下代码

 

public class Singleton {
  private Singleton(){}
    
  static Singleton instance;
    
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

这段代码看似完美,其实有着很大的问题,这个问题就出现在new关键字上。这个new编译之后大致对应以下几个指令操作:

1:分配一块内存M

2:在内存M上初始化Singleton对象

3:将M的地址赋值给instance变量

但是实际经过指令优化之后可能变成这样:

1:分配一块内存M

2:将M的地址赋值给instance变量

3:在内存M上初始化Singleton对象

优化后会导致如下这个问题:线程A执行正在new创建对象,已经到第二个指令处了,此时线程B来到了第一个判断所在的指令处,发现instance已经不为null,然后将其返回,这也就导致了线程B使用了一个未初始化完成的对象,如果在访问该对象的成员变量可能就会造成空指针异常,如下图:

补充知识点:线程切换是不会释放锁的。

2.2.4、JMM(Java Memory Model)

通过上一节我们可大概总结如下:导致可见性是因为CPU缓存,导致顺序性是因为编译优化,那也意味着解决可见性和顺序性的办法就是:禁用CPU缓存和编译优化,但是这样会导致程序性能下降严重。为此我们不得不做出一个合理的取舍,相对合理的办法就是:按需禁用CPU缓存和按需优化,也就是按照程序员的意愿来做。

这里就涉及到对于java程序员不得不知的JMM,Java 内存模型是个很复杂的规范,我们需要从多个维度来看待:

1、内存模型 这个概念。我们可以理解为:在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理计算机可以有不一样的内存模型,JVM 也有自己的内存模型。

2、JVM 中试图定义一种 Java 内存模型(Java Memory Model, JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序 在各种平台下都能达到一致的内存访问效果

3、从开发者角度而言,Java内存模型描述了在多线程代码中哪些行为是合法的,以及线程如何通过内存进行交互。它描述了“程序中的变量“ 和 ”从内存或者寄存器获取或存储它们的底层细节”之间的关系。

Java 内存模型规范了 JVM 如何提供按需禁用缓存和编译优化的方法。具体来说,这些方法包括 volatilesynchronizedfinal 三个关键字,以及 Happens-Before 规则

主内存与工作内存

JMM 的主要目标是 定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量(Variables)与 Java 编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数值对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。

JMM 规定了所有的变量都存储在主内存(Main Memory)中

每条线程还有自己的工作内存(Working Memory),工作内存中保留了该线程使用到的变量的主内存的副本。工作内存是 JMM 的一个抽象概念,并不真实存在,它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

 

线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成

注意:为了获得较好的执行效能,

1、JMM 并没有限制执行引擎使用处理器的特定寄存器或缓存来和主存进行交互,

2、JMM 也没有限制即时编译器调整指令执行顺序这类优化措施

JMM解决什么问题?

1、工作内存数据一致性:可见性问题

各个线程操作数据时会使用工作内存中的主内存中共享变量副本,当多个线程的运算任务都涉及同一个共享变量时,可能导致各自的共享变量副本不一致。如果真的发生这种情况,数据同步回主内存以谁的副本数据为准?

Java 内存模型主要通过一系列的数据同步协议、规则来保证数据的一致性。

2、约束指令重排序优化:有序性问题

Java 中重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。重排序可分为两类:编译期重排序和运行期重排序(处理器乱序优化),分别对应编译时和运行时环境。

同样的,指令重排序不是随意重排序,它需要满足以下几个条件:

  • 在单线程环境下不能改变程序运行的结果。即时编译器(和处理器)需要保证程序能够遵守 as-if-serial 属性。通俗地说,就是在单线程情况下,要给程序一个顺序执行的假象。即使经过重排序后的执行结果要与顺序执行的结果保持一致。
  • 存在数据依赖关系的不允许重排序。
  • 多线程环境下,如果线程处理逻辑之间存在依赖关系,有可能因为指令重排序导致运行结果与预期不同。

JMM内存交互

JMM 定义了 8 个操作来完成主内存和工作内存之间的交互操作。JVM 实现时必须保证下面介绍的每种操作都是 原子的(对于 double 和 long 型的变量来说,load、store、read、和 write 操作在某些平台上允许有例外 )。

  • lock (锁定) - 作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock (解锁) - 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read (读取) - 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • load (载入) - 作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use (使用) - 作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值得字节码指令时就会执行这个操作。
  • assign (赋值) - 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store (存储) - 作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后 write 操作使用。
  • write (写入) - 作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

如果要把一个变量从主内存中复制到工作内存,就需要按序执行 readload 操作;如果把变量从工作内存中同步回主内存中,就需要按序执行 storewrite 操作。但 Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。

JMM 还规定了上述 8 种基本操作,需要满足以下规则:

  1. read 和 load 必须成对出现store 和 write 必须成对出现。即不允许一个变量从主内存读取了但工作内存不接受,或从工作内存发起回写了但主内存不接受的情况出现。
  2. 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须把变化同步到主内存中。
  3. 不允许一个线程无原因的(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
  4. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign )的变量。换句话说,就是对一个变量实施 use 和 store 操作之前,必须先执行过了 load 或 assign 操作。
  5. 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。所以 lock 和 unlock 必须成对出现。
  6. 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值。
  7. 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定的变量。
  8. 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)

注意:规则6,规则8需要大家留意一下!!!

整体如下图所示:

Happens-Before

Java 内存模型里面,最晦涩的部分就是 Happens-Before 规则了,Happens-Before 规则最初是在一篇叫做 Time, Clocks, and the Ordering of Events in a Distributed System 的论文中提出来的,在这篇论文中,Happens-Before 的语义是一种因果关系。

如何来理解Happens-Before呢?如果就字面意思的话网上很多文章都翻译称:先行发生,Happens-Before 并不是说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的

打个比方:A Happens-Before B,可表明A操作的结果对B是可见的。

另外:Happens-Before有一个特性就是传递性:即 A Happens-Before B,B Happens-Before C,则 A Happens-Before C .

Happens-Before 约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定遵守 Happens-Before 规则,具体的一些规则如下:

1、程序的顺序性规则

这条规则是指在一个线程中,按照程序顺序(可能是重排序后的顺序),前面的操作 Happens-Before 于后续的任意操作,程序前面对某个变量的修改一定是对后续操作可见的

ClassReordering {
    int x = 0, y = 0;
    public void writer() {
        x = 1;
        y = 2;
    }
    public void reader() {
        int r1 = y;
        int r2 = x;
    }
}

2、volatile 变量规则

这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的读操作。比如下方代码:

class VolatileExample {
  int x = 0;
  volatile boolean v = false;
    // 线程A 先
  public void writer() {
    x = 42;
    v = true;
  }
    // 线程B 后
  public void reader() {
    if (v == true) {
      // 这里x会是多少呢?
    }
  }
}

注意:

1、我们声明一个 volatile 变量 volatile int x = 0,它表达的是:告诉编译器,对这个变量的读写,不能使用 CPU 缓存,必须从内存中读取或者写入

2、volatile 可以用来解决可见性问题

这里有两点:

线程B能看到线程A对变量v的写结果

结合顺序性规则和传递性特性可知在线程B中仍然能得到x的值为42

注意:第二点只有从jdk1.5开始才能满足,因为Java 内存模型在 1.5 版本对 volatile 语义进行了增强(禁止指令重排),1.5以前有可能x的值还为0。

3、管程中锁的规则

对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。

当然这里需要先大致了解一下什么是管程:

管程(Monitors,也称为监视器),是一种通用的同步原语,能够实现对共享资源的互斥访问,Java 中指的就是 synchronized,synchronized 是 Java 里对管程的实现。

管程中的锁在 Java 里是隐式实现的,例如下面的代码,在进入同步块之前,会自动加锁,而在代码块执行完会自动释放锁,加锁以及释放锁都是编译器帮我们实现的

int x = 10;

public void syn() {
    synchronized (this) { //此处自动加锁
      if (this.x < 12) {
        this.x = 12; 
      }  
    } //此处自动解锁
  }

从这个规则我们可以得出,释放锁之后,同步代码块中的操作结果对后续加锁时是可见的。同时结合前面讲的JMM内存操作可知,unlock时会将变量从工作内存刷到主内存中,获取锁时会从主内存中去读取变量值到工作内存中,也能证明锁的解锁 Happens-Before 于后续对这个锁的加锁。

4、线程启动规则

它是指主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。

static int var = 66;
// 主线程A
public static void t1() {
    Thread B = new Thread(()->{
      // 主线程调用B.start()之前
      // 所有对共享变量的修改,此处皆可见
      // 此例中,var==77
    });
    // 此处对共享变量var修改
    var = 77;
    // 主线程启动子线程
    B.start();
}

5、线程join规则

它是指主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B 完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的是对共享变量的操作结果可见。

static int var = 55;

//主线程A
public static void t1() {
    Thread B = new Thread(()->{
      // 此处对共享变量var修改
      var = 66;
    });
    
    // 主线程启动子线程
    B.start();
    //主线程等待子线程B结束
    B.join()
    // 子线程所有对共享变量的修改
    // 在主线程调用B.join()之后皆可见
    // 此例中,var==66
    
}

6、线程中断规则

对线程interrupt()方法的调用 Happens-Before 被中断线程的代码检测到中断事件的发生,比如我们可以通过Thread.interrupted()/isInterrupted方法检测到是否有中断发生。

7、对象终结规则

一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

2.2.5、volatile

volatile 是 JVM 提供的 最轻量级的同步机制,中文意思是不稳定的,易变的,用 volatile 修饰变量是为了保证变量在多线程中的可见性,它表达的含义是:告诉编译器,对这个变量的读写,需要基于主内存保证多CPU的缓存一致性。

volatile 变量的两个特性:解决可见性和有序性

  • 保证变量对所有线程的可见性:当一条线程修改了 volatile 变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点

    线程写 volatile 变量的过程:

    1. 改变线程工作内存中 volatile 变量副本的值
    2. 将改变后的副本的值立即从工作内存刷新到主内存

    线程读 volatile 变量的过程:

    1. 从主内存中读取 volatile 变量的最新值到线程的工作内存中
    2. 从工作内存中读取 volatile 变量的副本

 

// 多线程共享变量 使用volatile保障可见性
private  static volatile boolean running =  true;


private static void t1() throws InterruptedException {
    new Thread(()->{
        while (running) {
            //System.out.println("eat eat eat ");
            //ThreadUtil.sleepSeconds(1);
        }
        System.out.println("thread exit");
    }).start();

    TimeUnit.SECONDS.sleep(5);
    running = false;
}

注意:

1、volatile并不能保证并发操作的原子性,即不保证线程安全

// volatile 能保障可见性但是无法保障原子性,线程安全无法保障
private static volatile int count = 0;

public static void t2() throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            count++;
        }
    });

    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            count++;
        }
    });
    //启动两个线程
    t1.start();
    t2.start();

    //等待两个线程执行结束
    t1.join();
    t2.join();
    //输出count的最终结果
    System.out.println("count="+count);
}

可能在某一时刻,t1和t2 从主内存中读到了相同的count=100,然后经过工作内存操作之后均为101,t1和t2 将工作内存中的101刷到主内存,虽然刷新了2次但是最终的结果还是101。

2、volatile修饰引用类型,它只能保证引用本身的可见性,不能保证所引用对象内部属性的可见性

static class A {
   /*volatile*/ boolean stop = false;

    private  void t3() {
        while (!stop) {

        }
        System.out.println("program stopped");
    }
}
// volatile 修饰引用类型,只能保证该引用是可见的,对于所引用对下的属性是不可见的
private static volatile A a = new A();

private static void t4() throws InterruptedException{
    new Thread(a::t3,"t1").start();
    TimeUnit.SECONDS.sleep(5);
    a.stop = true;
}
  • 禁止进行指令重排序,具体一点解释,禁止重排序的规则如下:

    • volatile变量时,可以确保volatile写之前的操作不会被编译器重排序到volatile写之后。
    • volatile变量时,可以确保volatile读之后的操作不会被编译器重排序到volatile读之前。

所以,单例模式的DCL写法需要使用volatile。

public class Singleton {
  private Singleton(){}
    
  static volatile Singleton instance;
    
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}
2.2.6、synchronized
锁概述

通过前面我们知道发生原子性的根源是CPU在执行完任意指令后都有可能发生线程切换。如果能够禁用线程切换的话那这个问题也就迎刃而解了。操作系统做线程切换是依赖 CPU 中断的,所以禁止 CPU 发生中断就能够禁止线程切换。

知识点:CPU中断

让CPU停下当前的工作任务,去处理其他事情,处理完后回来继续执行刚才的任务,这一过程便是中断。

可参考知乎文章:一文讲透计算机的“中断” - 知乎

当然这种方案在单核CPU是可行的,但是在多核CPU中就不行了,为什么?我们来分析一下

我们以在32位CPU上执行long 型变量的写操作为例:long 型变量是 64 位,在 32 位 CPU 上执行写操作会被拆分成两次写操作(写高 32 位和写低 32 位,如下图所示)

在单核 CPU 场景下,同一时刻只有一个线程执行,禁止 CPU 中断,意味着操作系统不会重新调度线程,也就是禁止了线程切换,获得 CPU 使用权的线程就可以不间断地执行,所以两次写操作一定是:要么都被执行,要么都没有被执行,具有原子性。

但是在多核场景下,同一时刻,有可能有两个线程同时在执行,一个线程执行在 CPU-1 上,一个线程执行在 CPU-2 上,此时禁止 CPU 中断,只能保证某个CPU 上的线程连续执行,并不能保证同一时刻只有一个线程执行,如果这两个线程同时写 long 型变量高 32 位的话,那就有可能出现一些诡异 的Bug 了。

也就是说真正保证并发原子性的是:同一时刻只有一个线程执行,这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。

在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。

加锁是我们能想到的最直接也是最通用的互斥解决方案,加锁的模型如下图所示:

 

这里需要注意的地方:

1、就是锁和要保护资源之间的对应关系,图中虚线部分,很多时候就是忘记了这个关系从而导致了很多了问题

2、锁并不能并不能改变CPU时间片切换的特点,只是当其他线程要访问这个资源时,发现锁还未释放,所以只能等待。同时也说明线程切换是不会释放锁的。

基本用法

Java 语言提供的 synchronized 关键字,就是锁的一种实现。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块,一般的用法如下:

static int i = 0;

// 1、修饰非静态方法
public synchronized void foo() {
    i++;
}

// 2、修饰静态方法
public static synchronized void bar() {
    i--;
}


Object obj = new Object();

public void car() {
    //3、修饰代码块
    synchronized (obj) {
        i+=2;
    }
}


public static void main(String[] args) {

}

回顾前面讲的互斥锁模型,结合我们的代码,有几个要注意的问题:

1、加锁和解锁操作在哪里体现的?

synchronized 的加锁和解锁是隐式实现的,可以查看字节码

2、synchronized 的锁对象是什么,也就是说锁定的是哪个对象?

  • 如果修饰的是代码块,锁对象是我们自己指定的,指定哪个对象就锁定哪个对象。
  • 如果修饰的是非静态方法,锁定的是当前实例对象 this
  • 如果修饰的是静态方法,锁定的是当前类的 Class 对象。

再来看如下代码:

static class AddOneProblem {

    long i = 0L;


    /**
     *  我们知道:i+=1 并非原子操作,会有线程安全问题
     *  要想得以解决就可以加锁
     */
    public synchronized void addOne() {
        i+=1;
    }

}

对于addOne方法,添加了synchronized修饰之后,无论是单核 CPU 还是多核 CPU,只有一个线程能够执行 addOne() 方法,所以一定能保证原子操作。

至于可见性,前面提到的Happens-Before规则中有一条管程中的锁规则:对一个锁的解锁 Happens-Before 于后续对这个锁的加锁,再结合Happens-Before 的传递性原则,我们知道,addOne方法中+1操作的结果肯定会在释放锁之前刷到主内存,锁释放后下一个进入到addOne方法的线程获取锁时能够获取到上一个线程的操作结果。即前一个线程在临界区修改的共享变量,对后续进入临界区的线程是可见的。

另外:也体现出synchronized 加锁的含义不仅仅局限于互斥行为,还包括内存可见性。

执行解锁操作时会将工作内存中的共享变量刷到主内存(参考JMM中的unlock)。

执行加锁操作时会清空工作内存中共享变量副本的值,需要使用时从主内存重新加载(参考JMM中的lock)。

但是要注意的是:使用锁来保证可见性太笨重,因为synchronized是线程独占的,其他线程会被阻塞,这里面还存在一些线程调度开销,因为它是靠操作系统内核互斥锁实现的。而volatile是相对轻量级的,但是synchronized除了保证可见性还能保证原子性,而volatile不能保证原子性。

锁和资源的关系

前面提到,受保护资源和锁之间的关联关系非常重要,他们的关系是怎样的呢?

一个合理的关系是:锁和受保护资源之间的关联关系是 1:N 的关系。

但有时候我们写出的代码往往破坏了这个关系,我们举几个例子:

1、多把锁保护同一个资源的情况 :现实世界中可以,并发编程领域不行

class LR{
    static long i = 0L;

    synchronized void subOne() {
        i-=1;
    }

    static synchronized void addOne() {
        i+=1;
    }
}

代码是用两个锁保护一个资源。这个受保护的资源就是静态变量 i,两个锁分别是 `thisLR.class。我们可以用下面这幅图来形象描述这个关系。由于临界区 subOne() 和 addOne() 是用两个锁保护的,因此这两个临界区没有互斥关系,临界区 addOne() 对 变量i的修改对临界区 subOne() 也没有可见性保证,这就导致并发问题了。

 

2、一把锁如何保护多个资源

一把锁可以保护多个资源,但是这里就涉及到一个锁粒度的问题。需要仔细把握,掌握的不好就会造成问题。

Linux内核同步机制

POSIX threads(简称pthreads)是POSIX的线程标准,定义了创建和操纵线程的一套APII,我们需要的互斥机制就是用pthreads提供的锁机制(lock)来对多个线程之间共 享的临界区(Critical Section)进行保护。

pthreads提供的锁机制如下:

1、Mutex(互斥量):pthread_mutex_t,通过对该结构的操作,来判断资源是否可以访问,Mutex属于sleep-waiting类型的锁,例如在多核机器上有两个线程A,B,如果此时锁被A持有,那么B就会被阻塞,在等待队列中等待。

man -k mutex

pthread_mutex_consistent (3p) - mark state protected by robust mutex as consistent
pthread_mutex_destroy (3p) - destroy and initialize a mutex
pthread_mutex_getprioceiling (3p) - get and set the priority ceiling of a mutex (REALTIME THREADS)
pthread_mutex_init (3p) - destroy and initialize a mutex
pthread_mutex_lock (3p) - lock and unlock a mutex
pthread_mutex_setprioceiling (3p) - get and set the priority ceiling of a mutex (REALTIME THREADS)
pthread_mutex_timedlock (3p) - lock a mutex (ADVANCED REALTIME)
pthread_mutex_trylock (3p) - lock and unlock a mutex
pthread_mutex_unlock (3p) - lock and unlock a mutex

 2、Spin lock(自旋锁):pthread_spinlock_t,属于busy-waiting类型的锁,它不会引起调用者睡眠等待,如果获取不到锁则进入忙等待,它会不停的尝试去获取锁,俗称自旋,获取锁的性能相对较高,但是费CPU,所以自旋锁不应该被长时间的持有。

man -k spin

pthread_spin_destroy (3p) - destroy or initialize a spin lock object (ADVANCED REALTIME THREADS)
pthread_spin_init (3p) - destroy or initialize a spin lock object (ADVANCED REALTIME THREADS)
pthread_spin_lock (3p) - lock a spin lock object (ADVANCED REALTIME THREADS)
pthread_spin_trylock (3p) - lock a spin lock object (ADVANCED REALTIME THREADS)
pthread_spin_unlock (3p) - unlock a spin lock object (ADVANCED REALTIME THREADS)
3、 Condition Variable(条件变量):pthread_cond_t,条件变量是利用线程间共享的全局变量,进行同步的一种机制

man -k cond
4、Read/Write Lock(读写锁):pthread_rwlock_t,读写锁是用来解决读多写少问题的,读操作可以共享,写操作是排他的。


man -k rwlock
 

另外内核还提供了信号量(semaphore)机制,也可用于互斥锁的实现

5、semaphore:sem_t

man -k sem
 

下面从不同的层面来认识synchronized关键字的底层实现。

synchronized源码层面

源码层面是最好理解的,代码如下,这里不再赘述

public static void synClass() {
    Object obj = new Object();
    synchronized (obj) {

    }
}
synchronized字节码层面

下面我们来看一看上面的代码生成的字节码

 0 new #2 <java/lang/Object>
 3 dup
 4 invokespecial #1 <java/lang/Object.<init> : ()V>
 7 astore_1
 8 aload_1
 9 dup
10 astore_2
11 monitorenter
12 aload_2
13 monitorexit
14 goto 22 (+8)
17 astore_3
18 aload_2
19 monitorexit
20 aload_3
21 athrow
22 return


其中跟synchronized关键字相关的就是这样的字节码

monitorenter
........
monitorexit
monitorenter主要是获取监视器锁,monitorexit主要是释放监视器锁
synchronized jvm层面

如果一旦获取了某个对象的锁,我们来看一下,获取到对象锁前后该对象有什么变化

public static void synJvm() {
    Object obj = new Object();
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    synchronized (obj) {
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());

    }
}
使用JOL打印对象的内存结构,添加如下依赖:

xxxxxxxxxx
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
    <!--<scope>provided</scope>-->
</dependency>
打印输出的结果如下:

xxxxxxxxxx
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION      VALUE
      0     4    (object header)  01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4    (object header)  00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4    (object header)  e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4    (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION      VALUE
      0     4    (object header)  58 f3 2f 92 (01011000 11110011 00101111 10010010) (-1842351272)
      4     4    (object header)  a3 00 00 00 (10100011 00000000 00000000 00000000) (163)
      8     4    (object header)  e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4    (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

在锁的使用过程中伴随着一系列的锁升级过程。

markword

通过打印一个Object对象加锁前后内存布局的变化可知,对一个对象使用synchronized关键字加锁,锁信息是存储在对象头markword中的。我们可以从JVM源码中找到关于对象头markword的说明

src\share\vm\oops\markOop.hpp

//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

markword图示如下:

我们发现,markword的后三位被设定成了跟锁相关的标志位,其中有两位是锁标志位,1位是偏向锁标志位。

锁升级

前面我们看到了synchronized在字节码层面是对应monitorentermonitorexit,而真正实现互斥的锁其实依赖操作系统底层的Mutex Lock来实现,首先要明确一点,这个锁是一个重量级的锁,由操作系统直接管理,要想使用它,需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的。

确实jdk1.6之前每次获取的都是重量级锁,无疑在很多场景下性能不高,故jdk1.6对synchronized做了很大程度的优化,其目的就是为了减少这种重量级锁的使用。

整体锁升级的过程大致可以分为两条路径,如下:

 1、偏向锁未启动,默认轻量级锁

 

// 未使用过锁的状态
public static void noSyn() throws InterruptedException {
    Object obj = new Object();
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    //如果调用了hashcode
    int hashCode = obj.hashCode();
    System.out.println("调用hashcode");
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    System.out.println("尝试加锁");
    //使用synchronized
    synchronized (obj) {
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    }
    System.out.println("退出锁,查看一下");
    //退出锁 查看obj
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    System.out.println("开始有竞争了");
    // 竞争一下子
    for (int i=0;i<2;i++) {
        new Thread(()->{
            synchronized (obj) {
                System.out.println(ClassLayout.parseInstance(obj).toPrintable());
            }
        }).start();
    }
    TimeUnit.SECONDS.sleep(2);
    System.out.println("退出竞争了");
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}

轻量级锁:线程在自己的线程栈生成Lock Record,使用CAS的方式将markword设置为指向自己线程LOCK Record的指针,设置成功者得锁。竞争会让锁膨胀为重量级锁。

2、偏向锁启动

偏向锁,偏向的是第一个来获取锁的线程。所谓上偏向锁,指的是获取锁的线程在markword中写自己的线程ID的过程,偏向锁升级为轻量级锁时首先要撤销偏向锁,如何设置轻量级锁。

偏向锁默认是打开的,但是启动有一个时延,默认4s,之所以要延迟,是因为JVM虚拟机自己有一些默认的启动线程,里面有好多sync代码,这些代码启动时就肯定会有竞争,如果直接使用偏向锁,就会造成偏向锁不断的进行锁撤销和锁升级的操作,效率较低。

static void biasebdLocking() throws InterruptedException {
        Thread.sleep(5000);//或开启-XX:BiasedLockingStartupDelay=0
         System.out.println("等待偏向锁启动");
        Object o = new Object();
         System.out.println(ClassLayout.parseInstance(o).toPrintable());//偏向状态正常开启
         System.out.println("开始获取锁");
        synchronized (o){
            //偏向锁执行
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
        //代码块已退出
        System.out.println("锁释放");
        //再次打印对象 o 的 markword 可以看出对象依然是偏向状态 Thread ID被设置为主线程
         System.out.println(ClassLayout.parseInstance(o).toPrintable());
         System.out.println("再开一个线程获取锁");
         new Thread(()->{
             synchronized (o){
                 System.out.println(ClassLayout.parseInstance(o).toPrintable());//我们可以看到偏向被撤销
             }
         }).start();
         TimeUnit.SECONDS.sleep(1);
         System.out.println("锁释放");
         System.out.println(ClassLayout.parseInstance(o).toPrintable());
         System.out.println("有竞争");
        //开启子线程
         for (int i=0;i<2;i++) {
             new Thread(()->{
                 synchronized (o){
                     System.out.println(ClassLayout.parseInstance(o).toPrintable());//我们可以看到偏向被撤销
                 }
             }).start();
         }

        Thread.sleep(1000);
         System.out.println("锁被释放");
        //我们再次查看对象 o 的mark word 偏向被撤销(无锁状态)
         System.out.println(ClassLayout.parseInstance(o).toPrintable());

    }

markword后三位:1 01

细心的你也许会发现,还未加锁时,对象的锁状态位就已经是 101了,的确,偏向锁一旦启动后,这时候New出来的对象就是匿名偏向锁对象 ,就是说他已经就是偏向锁了,但是没有线程ID,里面空的。有线程来抢,将自己的ID贴出来,就是偏向锁。

另外注意:如果已启动偏向锁,但是加锁前调用了hashcode,则无法使用偏向锁 原因是markword中存了hashcode后没位置存偏向锁线程id了,加锁时直接就是轻量级锁了。

另外:有锁升级,是不是也有锁降级呢?

Hotspot JVM锁是否可以降级? - 知乎

STW

锁消除,锁粗化

锁消除(lock eliminate):虚拟机的运行时编译器在运行时如果检测到一些要求同步的代码上不可能发生共享数据竞争,则会去掉这些锁。

// 锁消除 append方法本身是添加了synchronized的,但sb变量是线程私有的不会发生竞争
public static void lockEliminate() {
    StringBuffer sb = new StringBuffer();
    sb.append("hello").append("ts");
}

锁粗化(Lock coarsening):将临近的代码块用同一个锁合并起来。

// 锁粗化
public static String lockCoarsening() {
    int i=0;
    StringBuffer sb = new StringBuffer();
    while (i<100) {
        sb.append(i);
        i++;
    }
    return sb.toString();
}
一些经验
  • 降低锁的等级

    能用对象级别的,尽量别用类锁,能用实例变量的不要用静态变量

  • 减少锁的时间 不需要同步执行的代码,能不放在同步块里面执行就不要放在同步快内,可以让锁尽快释放

  • 减少锁的粒度 共享资源数决定锁的数量。有一组资源定义一把锁,而不是多组资源共用一把锁,增加并行度,从而降低锁竞争,典型如分段锁

  • 减少加减锁的次数 假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都要加锁

  • 读写锁 业务细分,读操作加读锁,可以并发读,写操作使用写锁

  • 善用volatile

    volatile的控制比synchronized更轻量化,在某些变量上不涉及多步打包操作和原子性问题,可以加以运用。

    如ConcurrentHashMap的get操作,使用的volatile而不是加锁

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值