聊聊Java多线程之内存可见性

可见性(Visibility)

线程可见性简介

线程之间的可见性是指当一个线程修改一个变量,另外一个线程可以马上得到这个修改值。

假设我们有2个线程:A为读线程,读取一个共享变量的值,并根据读取到的值来判断下一步执行逻辑;B为写线程,对一个共享变量进行写入。很有可能B线程写入的值对于A线程是不可见的。

两个线程间的不可见性

我们用一个例子来表示这种线程间变量不可见的情况。Nonvisibility中的示例包含两个共享数据的线程。Cancel线程将更新标志,Work线程将一直循环直到读取到Cancel线程更新标志:



public class NonVisibilityDemo1 {
    public static void main(String[] args) throws InterruptedException {
        new WorkThread().start();
        Thread.sleep(1000);
        new CancelThread().start();
    }
}


class WorkThread extends Thread {

    @Override
    public void run() {
        System.out.println("WorkThread start");
        while (true) {
            if (ShareData.flag == 1) {
                break;
            }
        }
        System.out.println("WorkThread end");
    }
}

class ShareData {
    public static int flag = -1;
}


class CancelThread extends Thread {
    @Override
    public void run() {
        ShareData.flag = 1;
        System.out.println("CancelThread set flag=" + ShareData.flag);
    }
}


/**
Output:
WorkThread start
CancelThread set flag=1
...
...
...
程序一直运行不退出
**/

这个程序可能会一直循环下去,因为Work线程可能读取不到Cancel线程对于flag的写入而永远等待。

线程间的不可见性是怎样产生的

要理解线程间对共享变量的不可见性,需要大概理解CPU的工作流程。

先了解我们使用的程序变量可能存储的位置:

  • 每个CPU有寄存器,CPU对变量的运算需要从寄存器中读写变量
  • 除寄存器(Register)外还有高速缓存子系统(Cache),写缓冲器(Store Buffer),无效化队列 (Invalidate Queue)
  • 计算机的主内存

也就是说,我们对一个变量的读写操作,可能要途径:主内存->Cache->Store Buffer->寄存器->CPU。

那么可能产生变量不可见的情况就会有:

  1. 每个处理器都有自己的寄存器,不同的线程可能运行在不同的CPU上,例如线程A在CPU-1中运行更改了变量V的值从0到1,于此同时(瞬时),线程2在CPU-2中读取变量V的值仍然是0,这时对线程A对变量的操作则对线程B体现了不可见性。

  2. CPU对变量操作之后,需要将对该变量的更新写入到主内存中,而处理器对主内存并不是直接访问,而是通过该CPU的写缓冲器(Store Buffer)中,还没到达该处理器的Cache中,这时一个CPU的Store Buffer是无法于另一个CPU共享该变量的更新的,这样也产生了不可见性。

虽然一个CPU的Cache是不可以被另一个CPU直接读取的,但是处理器可以通过缓存一致性协议(Cache Coherence Protocol)来读取其他处理器的Cache中的数据,并且将数据同步该处理器的Cache中,这个过程称之为缓存同步。并且为了保证可见性,需要将CPU对变量做的更新最终写入到该CPU的高速缓存或者主内存中,这个过程称为冲刷处理器缓存
即通过冲刷处理器缓存来保证CPU对变量的更新冲刷到Cache中,通过缓存同步将对变量的更新同步到其他的处理器。

如何解决线程间不可见性

为了保证线程间可见性我们必须要保证对共享数据的写操作和读操作都是同步的,也就是写操作线程和读操作线程都需要在同一个锁上进行同步。我们一般有3种方式去保持同步:

  • volatile:只保证可见性
  • Atomic相关类:保证可见性和原子性
  • Lock: 保证可见性和原子性

使用volatile关键字来解决可见性问题

Java提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。

我们尝试更改上一个示例,使用volatile关键字来修饰共享数据ShareData.flag

public class VisibilityByVolatileDemo {
    public static void main(String[] args) throws InterruptedException {
        new WorkThread().start();
        Thread.sleep(1000);
        new CancelThread().start();
    }
}


class WorkThread extends Thread {

    @Override
    public void run() {
        System.out.println("WorkThread start");
        while (true) {
            if (ShareData.flag == 1) {
                break;
            }
        }
        System.out.println("WorkThread end");
    }
}

class ShareData {
    public static volatile  int flag = -1;
}


class CancelThread extends Thread {
    @Override
    public void run() {
        ShareData.flag = 1;
        System.out.println("CancelThread set flag=" + ShareData.flag);
    }
}

/**
Output:
WorkThread start
WorkThread end
CancelThread set flag=1
程序运行结束
**/

由于对ShareData.flag使用了volatile关键字进行了修饰,程序可以正常结束,并且读线程可以正常的访问到写线程对共享数据flag的修改从而正常结束。

使用AtomicInteger类来解决可见性问题

我们再尝试更改上一个示例,使用AtomicInteger类来包装共享数据ShareData.flag:


public class VisibilityByAtomicDemo {
    public static void main(String[] args) throws InterruptedException {
        new WorkThread().start();
        Thread.sleep(1000);
        new CancelThread().start();
    }
}


class WorkThread extends Thread {

    @Override
    public void run() {
        System.out.println("WorkThread start");
        while (true) {
            if (ShareData.flag.get() == 1) {
                break;
            }
        }
        System.out.println("WorkThread end");
    }
}

class ShareData {
    public static AtomicInteger flag = new AtomicInteger(-1);
}


class CancelThread extends Thread {
    @Override
    public void run() {
        ShareData.flag.set(1);
        System.out.println("CancelThread set flag=" + ShareData.flag);
    }
}

/**
Output:
WorkThread start
WorkThread end
CancelThread setFlag flag=1
程序运行结束
**/

由于ShareData.flag使用的类型是AtomicInteger,写线程对flag的修改对于读线程是可见的,这样写线程可以读取到flag被更新为1并正常退出。

使用synchronized来解决可见性问题

使用synchronized关键字对操作加锁也可以保证线程间的可见性,并且保证操作的原子性。内置锁可以用于确保某个线程以一种可预测的方式来查看另一个线程的执行结果。加锁的含义不仅仅局限于互斥行为,还包括内存可见性。
我们再构造一个示例来说明synchronized关键字所起的作用。首先我们还是需要2个线程,一个读线程,一个写线程,然后把读写操作封装到ShareData中,然后观察在没有synchronized关键字修饰时程序
的运行情况。


public class VisibilityBySynchronizedDemo {
    public static void main(String[] args) throws InterruptedException {
        new WorkThread().start();
        Thread.sleep(1000);
        new CancelThread().start();
    }
}


class WorkThread extends Thread {

    @Override
    public void run() {
        System.out.println("WorkThread start");
        while (true) {
            if (ShareData.getFlag() == 1) {
                break;
            }
        }
        System.out.println("WorkThread end");
    }
}

class ShareData {
    private static  int flag = -1;

    public static synchronized int getFlag() {
        return flag;
    }

    public static synchronized void setFlag(int value) {
        flag = value;
    }
}


class CancelThread extends Thread {
    @Override
    public void run() {
        ShareData.setFlag(1);
        System.out.println("CancelThread setFlag flag=" + ShareData.getFlag());
    }
}

/**
Output:
WorkThread start
WorkThread end
CancelThread setFlag flag=1
程序结束
**/

由于getFlag()setFlag()方法都使用了synchronized关键字修饰,保证了原子性和可见性,程序正常结束。

小结

在Java平台中,如何保证可见性呢?也就是我们上面提到的

  • volatile:只保证可见性
  • Atomic相关类:保证可见性和原子性
  • Lock: 保证可见性和原子性

其实无论对于上述的何种方式,其本质都是会使相应的CPU进行刷新处理器缓存动作,来保证共享变量的可见性。

你可能感兴趣的:(聊聊Java多线程之内存可见性)