Java多线程:synchronized和volatile

本文章是Java多线程系列的一篇文章,其他文章:
Java多线程:锁的底层实现
Java多线程:synchronized和volatile
Java多线程:JUC包-锁的封装
Java多线程:Thread的使用,以及wait(),notify(),notifyAll()
Java多线程:线程池

一、什么是锁

1.1 为什么需要锁: CPU的内存模型

大家都知道,计算机在执行程序时,每条指令都是在CPU中执行的,而数据首先会被从主存取到CPU的cache中,但是当变量在cache中被改变时,是不会立马写回主存的,那么问题就来了,在多核心CPU中,如果执行下面一段代码:i = i + 1;在多线程环境中就会出问题,如果这里不明白可以参考下图或者百度内存模型,这里不再赘述

Java多线程:synchronized和volatile_第1张图片

1.2 锁解决了什么问题

锁主要提供了三种特性:原子(atomic)、互斥(mutual exclusion) 和可见性(visibility)。
- 原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
- 可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
- 有序性:即程序执行的顺序按照代码的先后顺序执行。

指令重排序(Instruction Reorder): 一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先 后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。重排序有利于充分使用流水线,进而达到超标量的效果。
假如按照如下顺序执行三条语句:

int a = 5;     //1
int b = 10;    //2
int c = a + 1; //3

假设用的同一个寄存器,
这三条语句,如果按照顺序一致性,执行顺序为①②③寄存器要被读写三次;
但为了降低重复读写的开销,编译器会交换第二和第三的位置,即执行顺序为①③②

1.3 如何实现锁

硬件层面

硬件层面,为了解决缓存不一致性问题,通常来说有以下2种解决方法:

在总线加LOCK#锁

在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LCOK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。
但是这个方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。

缓存一致性协议

但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。
所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

CAS原子操作: cmpxchg指令
作用:比较并交换操作数.
如:CMPXCHG r/m,r 将累加器AL/AX/EAX/RAX中的值与首操作数(目的操作数)比较,如果相等,第2操作数(源操作数)的值装载到首操作数,zf置1。如果不等, 首操作数的值装载到AL/AX/EAX/RAX并将zf清0
该指令只能用于486及其后继机型。第2操作数(源操作数)只能用8位、16位或32位寄存器。第1操作数(目地操作数)则可用寄存器或任一种存储器寻址方式。

语言层面

Java提供了volatile和synchronized两个关键字来进行同步操作。其中,volatile是轻量级锁,只保证了共享变量的可见性,即当一个线程修改了变量的值,另一个线程能立马看到修改后的值。而synchronized则是重量级锁,使得共享变量可在多个线程间互斥。
关于Java中锁的底层实现,参考这篇文章:Java多线程:锁的底层实现,本文只讨论如何使用volatile和synchronized

二、volatile: 保证共享变量原子操作的可见性

1.volatile关键字的两层语义

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
- 1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 2)禁止进行指令重排序。

2.volatile保证原子性吗?

先来看下面一段代码:

public class Test {
    public volatile int inc = 0;

    public void increase() {
        inc++;
    }

    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }

        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

  大家想一下这段程序的输出结果是多少?也许有些朋友认为是10000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于10000的数字。

  可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。

  这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。

  在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:

  假如某个时刻变量inc的值为10,

  线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;

  然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。

  然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。

  那么两个线程分别进行了一次自增操作后,inc只增加了1。

  解释到这里,可能有朋友会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则,但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。

  根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。

3. volatile的使用场景

synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
1. 对变量的写操作不依赖于当前值
2. 该变量没有包含在具有其他变量的不变式中

实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
事实上,我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

下面列举几个Java中使用volatile的几个场景。

3.1. 状态标记量

volatile boolean inited = false;
//线程1:
context = loadContext();  
inited = true;            

//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);

3.2. double check

class Singleton{
    private volatile static Singleton instance = null;

    private Singleton() {

    }

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

三、synchronized

1. 语法

1.1. 修饰代码块

public void method()
{
   synchronized(this) {//括号里是需要加锁的对象,如果为null会触发空指针异常
      // todo
   }
}

1.2. 修饰方法

public synchronized void method()
{
   // todo
}

1.3. 修饰静态方法

public synchronized static void method() {
   // todo
}

四、注意事项

1. synchronized关键字不能继承。

synchronized关键字不能继承。
虽然可以使用synchronized来定义方法,但synchronized并不属于方法定义的一部分,因此,synchronized关键字不能被继承。如果在父类中的某个方法使用了synchronized关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上synchronized关键字才可以。当然,还可以在子类方法中调用父类中相应的方法,这样虽然子类中的方法不是同步的,但子类调用了父类的同步方法,因此,子类的方法也就相当于同步了。这两种方式的例子代码如下:

class Parent {
   public synchronized void method() { }
}
class Child extends Parent {
   //在子类方法中加上synchronized关键字
   public synchronized void method() { }
}

2. 定义接口方法时不能使用synchronized

在定义接口方法时不能使用synchronized关键字。

3. 构造方法不能使用synchronized

构造方法不能使用synchronized关键字,但可以使用synchronized代码块来进行同步。

参考资料:
Java并发编程:volatile关键字解析
ABA问题
synchronized的用法

你可能感兴趣的:(编程语言:Java,虚拟机:JVM,Java多线程)