欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Java最佳实践经验第78条:同步访问共享的可变数据

程序员文章站 2022-07-12 18:00:12
...

本来计划一天分享一条,但是博主最近临近面试,时间上实在不太充裕,博主会尽量保证最高的效率撰写。

摘要

众所周知,我们在设计程序的时候,设计一个良好可正常运行的并发程序的难度,是远大于设计一个单线程程序的,因为有更多的可能会产生错误,有些失败想要复现也是很困难的。但只要注意一些多线程的编写规范,很多问题是可以有效避免的。

1、同步的二重含义

1.1、互斥访问

关键字synchronized可以保证在同一时刻,只有一个线程可以执行一个方法,或者某一个代码块。这是一个互斥 的概念,但很多程序员把同步的概念仅仅理解为一种互斥的方式,这是不完整的。

1.2、状态一致和可见性

如果没有同步,一个线程的变化就不能被其他线程看到,同步不仅可以阻止对象处于不一致的状态之中,它还可以保证进入同步方法或者同步代码块的每个线程,都能看到由同一个锁保护的之前的所有的修改效果。

2、对于各线程共享的可变数据,一定要能够同步访问

Java语言规范保证读或者写一个变量是原子的(atomic),除非这个变量的类型为long或者double。换句话说,读取一个非long或者double类型的变量,可以保证返回值是某个线程保存在该变量中的,即使多个线程在没有同步的情况下并发地修改这个变量也是如此。

为了提高性能,在读或写原子数据的时候,应该避免使用同步。这个建议是非常危险而错误的
虽然语言规范保证了线程在读取原子数据的时候,不会看到任意的数值,但是它并不保证一个线程写入的值对于另一个线程将是可见的,这是由Java语言规范中的内存模型决定的,它规定了一个线程所做的变化何时及如何变成对其他线程可见。

举一个例子:阻止一个线程妨碍另一个线程的任务。Java类库中虽然提供了Thread stop方法,但是在很久以前就不提倡使用该方法了,因为它本质上是不安全的——使用它会导致数据被破坏,所以千万不要使用·Thread stiop·方法去阻止一个线程。我们可以采用使用另一个线程poll(轮询)的方式来实现。

public class Demo1 {
    private static boolean stopRequested;

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested){
                i++;
            }
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

stopRequested是一个布尔类型的属性,初始值为false,通过主线程将其设置为true,来终止backgroundThread线程的循环。从逻辑上乍一看好像没有问题,你是否期待这个程序会在运行1秒后停止呢?
运行结果如下所示:
Java最佳实践经验第78条:同步访问共享的可变数据
永远不会停止,因为我的后台线程在一直循环

问题出在哪呢?

由于没有同步,就不能保证后台线程何时“看到”主线程对stopRequested = true的改变,JVM会将

while(!stopRequested){
	i++;
}

这一代码转变为:

if(!stopRequested){
	while(true){
		i++;
	}
}

导致后台线程永远看不到主线程对stopRequested属性发出的值改变命令,这种优化称作为提升(hoisting),正是JVM的工作,导致的结果是一个活性失败问题:这个程序事实上并没有得到提升。修正这个问题是方式有两种。

2.1 同步访问stopRequested属性

public class Demo1_2 {
    private static boolean stopRequested;

    private static synchronized void requestStop(){
        stopRequested = true;
    }

    private static synchronized boolean stopRequested(){
        return stopRequested;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested()){
                i++;
            }
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        requestStop();
    }
}

Java最佳实践经验第78条:同步访问共享的可变数据
可以发现在1s后程序停止了运行。这里要注意的是读和写方法都需要被同步,否则无法100%保证同步能起作用

2.1 使用volatile声明stopRequested属性

如果使用volatile声明stopRequested属性,就可以省略synchronized同步锁。volatile修饰符不执行互斥访问,但可以保证另一个线程能立刻看到属性值的改变。

public class Demo1_3 {
    private static volatile boolean stopRequested;

    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            int i = 0;
            while (!stopRequested){
                i++;
            }
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1);
        stopRequested = true;
    }
}

3、谨慎地使用volatile修饰符

来看下面这个例子:

public class Demo1_4 {
    private static volatile int nextSerialNumber = 0;

    public static int generateSerialNumber() {
        return nextSerialNumber++;
    }
}

这个方法的目的是要确保每次调用都返回不同的值(只要不超过int的上限2^32次调用)。这个方法的状态只包含了一个可原子访问的属性nextSerialNumber,看起来似乎不需要任何同步来保护它。然而,如果没有同步来保护方法generateSerialNumber,这个方法仍然无法正确地工作。

其中的问题出在操作符(++)不是原子的,它包含了两项操作:先读取nextSerialNumber的原值,再把+1的结果写回nextSerialNumber。如果在这两步操作之间有第二个线程读取了这个属性,那么就会读到错误的结果。这就是安全性失败

对于安全性失败也有两种解决办法。

3.1 为方法上synchronized修饰符

只要为方法加上同步,就可以严格保证++过程中只有一个线程访问,确保每个调用都会看到调用之前的效果。这样volatile也就自然地可以删去了。为了使得这个方法更加可靠,我们用long来代替int

public class Demo1_5 {
    private static long nextSerialNumber = 0;

    public static synchronized long generateSerialNumber() {
        return nextSerialNumber++;
    }
}

3.2 使用AtomicLong

它是java.util.concurrent.atomic的组成部分,这个包为单个变量上进行免锁定、线程安全的编程提供了基本类型。它同时提供了同步的通信效果和原子性。这正是让generateSerialNumber顺利执行的完美方案。

public class Demo1_6 {
    private static final AtomicLong nextSerialNumber = new AtomicLong();

    public static long generateSerialNumber() {
        return nextSerialNumber.getAndIncrement();
    }
}

4、安全发布

避免本篇前文中所涉及到的一系列问题的最佳办法是:不共享可变的数据。要么共享不可变数据,要么压根不共享数据。换句话说,就是将可变的数据限制在单个线程内,不对外开放。如果采用了这一策略,对它建立文档就很重要。

总结前面的案例,我们可以发现导致线程不安全的主因主要出在数据写入不可见,所以如果我们让一个线程在短时间内修改一个数据对象,然后与其他线程共享,这是可以接受的,它只同步共享对象引用的动作。这种对象被称作高效不可变。将这种对象引用从一个线程传递到其他线程被称作安全发布。安全发布对象引用有很多种方法:可以将它保存在静态属性中,作为类初始化的一部分;可以将它保存在volatile属性、final属性或者通过正常锁定访问的属性中;或者可以将它放到并发的集合中。

缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原语值变得更加困难。实现安全发布对象的一种技术就是将对象引用定义为 volatile 类型,并将生成对象和读取对象分离到不同的线程中,下面展示了一个示例,其中后台线程在启动阶段从数据库加载一些数据。其他线程在能够利用这些数据时,在使用之前将检查这些数据是否已经准备完毕,并无权对其进行修改。

public class Demo1_7 {
    public static void main(String[] args) throws InterruptedException {
        Thread backgroundThread = new Thread(() -> {
            BackgroundFloobleLoader.initInBackground();
        });
        backgroundThread.start();

        TimeUnit.SECONDS.sleep(1); //静止一秒让后台进程运行完
        SomeOtherClass.doWork();
    }
}

class BackgroundFloobleLoader {
    private static volatile Flooble theFlooble;

    public static Flooble getFlooble(){
        return theFlooble;
    }
    public static void initInBackground() {
        theFlooble = new Flooble();
    }
}

class SomeOtherClass {
    private static void doSomething(Object obj){
        System.out.println("我成功做了接收到了后台线程发布的数据: " + obj);
    }

    public static void doWork() {
        if (BackgroundFloobleLoader.getFlooble() != null) {
            doSomething(BackgroundFloobleLoader.getFlooble());
        } else {
            System.out.println("后台线程还没准备好要发布的数据!");
        }
    }
}

class Flooble{

}

看下输出的结果:
Java最佳实践经验第78条:同步访问共享的可变数据
因为我们这里没有私有了BackgroundFloobleLoader类中的theFlooble属性,只提供了getter方法,也就相当于把这个属性变成了不可变的对象,防止了其他线程的写入,确保线程安全。

总结

总而言之,当多个线程共享可变数据的时候,每个读或写数据的线程都必须执行同步。如果没有同步,就无法保证一个线程所做的修改可以被另一个线程获知。未能同步共享可变数据会造成程序的活性失败安全性失败。这样的失败是最难调试的,它们可能是间接性的,且与时间相关,且程序的行为在不同的虚拟机上也会有根本不同。

如果只需要实现线程之间的交互通信,而不需要实现互斥,那么volatile修饰符就是一种可以接受的同步形式。