并发编程三要素:可见性、原子性、有序性

一、介绍

1、什么是可见性、原子性、有序性?

  1. 可见性(visibility):指一个线程对共享变量的修改能够被其他线程立即看到的特性。在多线程环境下,如果一个线程修改了一个共享变量的值,那么其他线程可能无法立即看到这个修改,因为线程之间有可能存在缓存不一致的问题。为了保证可见性,可以使用volatile关键字或者显式地使用锁来实现。

  2. 原子性(atomicity):指一个操作是不可分割的、完整的,要么全部执行成功,要么全部不执行,不存在执行一半的情况。在多线程环境下,如果一个操作不是原子性的,那么可能会发生竞态条件(race condition)等问题,导致程序出现不可预期的错误。为了保证原子性,可以使用synchronized关键字或者使用Atomic类中提供的原子操作。

例如:在java中count++ 与 Person person = new Person()就不具备原子性,因其在JVM中会变成多个指令顺序执行

  1. 有序性(ordering):指程序执行的顺序必须符合预期,不能出现乱序的情况。在多线程环境下,由于编译器、处理器、缓存等因素的影响,程序执行的顺序可能会出现不一致的情况,导致程序出现错误。为了保证有序性,可以使用volatile关键字或者显式地使用锁来实现。同时,Java提供了happens-before规则,它可以保证在特定情况下,操作的顺序是按照预期的顺序执行的。

2、举例说明

1、可见性

public class Main {
    public static void main(String[] args)  throws InterruptedException {
        HelloThread t = new HelloThread();
        t.start();
        Thread.sleep(1);
        t.running = false; // 标志位置为false
    }
}

class HelloThread extends Thread {
    public volatile boolean running = true;
    public void run() {
        int n = 0;
        while (running) {
            n ++;
            System.out.println(n + " hello!");
        }
        System.out.println("end!");
    }
}

注意到HelloThread的标志位boolean running是一个线程间共享的变量。线程间共享变量需要使用volatile关键字标记,确保每个线程都能读取到更新后的变量值。

为什么要对线程间共享的变量用关键字volatile声明?这涉及到Java的内存模型。在Java虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!

┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
           Main Memory
│                               │
   ┌───────┐┌───────┐┌───────┐
│  │ var A ││ var B ││ var C │  │
   └───────┘└───────┘└───────┘
│     │ ▲               │ ▲     │
 ─ ─ ─│─│─ ─ ─ ─ ─ ─ ─ ─│─│─ ─ ─
      │ │               │ │
┌ ─ ─ ┼ ┼ ─ ─ ┐   ┌ ─ ─ ┼ ┼ ─ ─ ┐
      ▼ │               ▼ │
│  ┌───────┐  │   │  ┌───────┐  │
   │ var A │         │ var C │
│  └───────┘  │   │  └───────┘  │
   Thread 1          Thread 2
└ ─ ─ ─ ─ ─ ─ ┘   └ ─ ─ ─ ─ ─ ─ ┘

这会导致如果一个线程更新了某个变量,另一个线程读取的值可能还是更新前的。例如,主内存的变量a = true,线程1执行a = false时,它在此刻仅仅是把变量a的副本变成了false,主内存的变量a还是true,在JVM把修改后的a回写到主内存之前,其他线程读取到的a的值仍然是true,这就造成了多线程之间共享的变量不一致。

因此,volatile关键字的目的是告诉虚拟机:

  • 每次访问变量时,总是获取主内存的最新值;
  • 每次修改变量后,立刻回写到主内存。

volatile关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。

如果我们去掉volatile关键字,运行上述程序,发现效果和带volatile差不多,这是因为在x86的架构下,JVM回写主内存的速度非常快,但是,换成ARM的架构,就会有显著的延迟。

2、原子性

原子性也是大家最常见的使用场景,即保证多线程安全操作;

在java语义中,有些操作是天生具备原子性,如下:

2.1、局部变量

Java中的局部变量只在方法的作用域内可见,只有当前线程可以访问它,因此局部变量天生具备线程安全性。

举个例子,假设有一个方法calcSum,用于计算从1到n的整数之和

public int calcSum(int n) {
    int sum = 0; // 局部变量sum
    for (int i = 1; i <= n; i++) {
        sum += i;
    }
    return sum;
}

sum是一个局部变量,它只在calcSum方法内部可见。在多线程环境下,每个线程都会拥有自己的执行栈和局部变量表,因此不会出现线程间共享变量的情况。每个线程都可以独立的执行calcSum方法,不会互相影响,因此该方法是线程安全的。

需要注意的是,如果方法中使用了共享变量(比如类的成员变量或静态变量),那么就需要进行线程同步操作,以保证线程安全性。

2.2、单原子操作

JVM规范定义了几种原子操作:

  • 基本类型(longdouble除外)赋值,例如:int n = m
  • 引用类型赋值,例如:List list = anotherList
//单条原子操作的语句不需要同步。例如:
public void set(String s) {
    this.value = s;
}

//对引用也是类似。例如:
public void set(String s) {
    this.value = s;
} 

不具备原子性的操作:

2.3、共享变量
public class SharedVariable {
    private int count = 0;

    public void increment() {
        count++;
    }

    public void decrement() {
        count--;
    }

    public int getCount() {
        return count;
    }
}

在这个示例代码中,incrementdecrement 方法对共享变量 count 进行递增和递减操作,但这些操作不具备原子性。如果多个线程同时调用这些方法,可能会出现竞态条件(race condition)导致计数器的值出现错误。

2.4、复合操作
public static Person person;

public Persion getPerson(String name, int age) {
  	person = new Person(name, age);
 		return person;
}

代码中的 getPerson 方法是线程不安全的,因为它对共享变量 person 进行了非原子性的读写操作。

多个线程同时调用 getPerson 方法时,可能会出现竞态条件(race condition),导致 person 变量的值出现错误。例如,一个线程在执行 person = new Person(name, age) 语句时,另一个线程可能会读取到 person 变量的旧值,导致返回的 Person 对象不是最新创建的对象。

3、有序性

单例模式中双重校验的使用

public class Singleton {
    private volatile static Singleton uniqueInstance;

    private Singleton() {}

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

在上面的代码中,volatile变量是为了解决程序的重排序问题,原因如下:

uniqueInstance = new Singleton() 这行代码并不是一个原子指令。使用 javap -c指令,可以快速查看字节码。

// 创建 Cache 对象实例,分配内存
0: new           #5                  // class com/query/Cache
// 复制栈顶地址,并再将其压入栈顶
3: dup
// 调用构造器方法,初始化 Cache 对象
4: invokespecial #6                  // Method "":()V
// 存入局部方法变量表
7: astore_1

从字节码可以看到创建一个对象实例,可以分为三步:

  1. 分配对象内存
  2. 调用构造器方法,执行初始化
  3. 将对象引用赋值给变量。

虚拟机实际运行时,以上指令可能发生重排序。以上代码 2,3 可能发生重排序,但是并不会重排序 1 的顺序,排序后的顺序如下:

  1. 分配对象内存
  2. 将对象引用赋值给变量**(此时uniqueInstance就不等于null了,但uniqueInstance并没有初始化!)**
  3. 调用构造器方法,执行初始化

如果出现重排序问题,此时变量也没有使用volatile修饰,那么该双重校验模式会出现异常,如下图:

并发编程三要素:可见性、原子性、有序性_第1张图片

并发编程三要素:可见性、原子性、有序性_第2张图片

故正确的双重检查锁定模式需要需要使用 volatilevolatile主要包含两个功能

  1. 保证可见性。使用 volatile 定义的变量,将会保证对所有线程的可见性。
  2. 禁止指令重排序优化。

由于 volatile 禁止对象创建时指令之间重排序,所以其他线程不会访问到一个未初始化的对象,从而保证安全性。

二、并发编程中常见关键字

在并发编程中常见以下几种关键字,他们中有些只具备三要素中的一种或两种,故在使用时开发人员要十分清楚不同关键字的使用场景,避免出现并发编程错误

1、volatile

2、synchronized、Lock

3、java.util.concurrent.atomic

4、static

三、先说结论

1、volatile关键字修饰的变量具有可见性、有序性和部分原子性。

可见性:当一个线程修改了volatile变量的值,该值会立即被写回主内存,同时其他线程在读取该变量时也会直接从主内存中读取,而不是从线程私有的内存中读取。因此,对volatile变量的修改对其他线程是可见的。

有序性:Java内存模型(JMM)定义了一些规则来保证多线程之间的操作顺序,volatile变量的读写操作会遵循这些规则,保证了读操作和写操作的顺序性,避免了出现一些奇怪的现象,比如指令重排序等。

部分原子性:volatile变量的单次读/写操作是原子性的(例如count=1赋值操作),但对于复合操作(例如i++)则不保证原子性。要实现原子性操作,可以使用synchronized关键字或者Lock来进行同步控制。

注意:虽然volatile关键字可以保证可见性、有序性和部分原子性,但并不能完全替代锁。在需要实现复杂的操作时,还是需要使用synchronized关键字或者Lock来进行同步控制。

2、synchronized、Lock具备可见性、有序性、原子性

synchronizedLock 锁都具备可见性、有序性和原子性。

  • 可见性:对于 synchronizedLock 锁而言,都能够保证在一个线程修改了共享变量的值之后,其它线程能够立即看到这个变量的最新值。在 synchronized 中,进入和退出同步块时,会自动执行锁的获取和释放操作,这些操作会将本地缓存中的数据刷新到主内存中,从而保证了可见性。在 Lock 锁中,当一个线程获取锁时,它会读取主内存中的最新值,将其存储到本地内存中,当它释放锁时,会将本地内存中的数据刷新到主内存中,从而保证了可见性。

  • 有序性:对于 synchronizedLock 锁而言,都能够保证在一个线程执行完毕后,其它线程才能执行被锁保护的代码块或方法。在 synchronized 中,对于同一个锁对象,同一时间只有一个线程能够执行被锁保护的代码块或方法,其它线程需要等待锁的释放,从而保证了执行的有序性。在 Lock 锁中,当一个线程获取锁时,其它线程需要等待锁的释放,从而保证了执行的有序性。

  • 原子性:对于 synchronizedLock 锁而言,都能够保证其中的操作是原子性的。当一个线程获得了锁,进入了被锁保护的代码块或方法,其它线程需要等待,直到这个线程执行完毕,释放锁之后,其它线程才能进入被锁保护的代码块或方法。这样就可以保证其中的操作是原子性的,避免了多个线程同时修改共享变量的值,导致数据出现错误的情况。

注意:在使用 Lock 锁时,需要手动进行锁的获取和释放操作,这就需要更加精细的控制,否则可能会导致死锁等问题。在 synchronized 中,锁的获取和释放是自动进行的,因此更加方便使用。而在性能上,Lock 锁通常比 synchronized 更加高效。

3、java.util.concurrent.atomic 包具备可见性、原子性、有序性

java.util.concurrent.atomic 下的类都具备可见性、有序性和原子性。这里以AtomicInteger举例:

  • 可见性:在多线程环境中,当一个线程修改了 AtomicInteger 对象的值后,其他线程可以通过 get() 方法获取到最新的值,这是因为 AtomicInteger 内部使用了 volatile 修饰的 value 变量,确保了其对多线程的可见性。

  • 原子性:AtomicInteger是Java中的一个原子类,提供了一种线程安全的整数类型。在多线程环境下,对于AtomicInteger的操作都是原子性的,即多个线程同时对一个AtomicInteger进行操作时,不会出现数据不一致的情况。

  • 有序性:java.util.concurrent.atomic 包下的类,例如 AtomicIntegerAtomicLong 等,都使用了 CAS(Compare And Swap)操作来保证有序性。CAS 操作包括三个操作数,分别是内存位置 V、期望值 A 和新值 B。如果当前内存位置的值等于期望值 A,则将该位置的值更新为新值 B。如果当前内存位置的值不等于期望值 A,则不做任何操作。通过使用 CAS 操作,能够保证操作的原子性,同时也能够保证操作的有序性。

注意: AtomicInteger提供的原子性只适用于单个操作,对于多个操作的复合操作,仍然需要使用synchronized关键字或者Lock来进行同步控制,以保证原子性和线程安全。

4、static具备部分原子性,但不具备可见性、有序性

static 修饰的变量具备可见性和有序性,但不具备原子性。

  • 可见性:static 修饰的静态变量在所有实例之间共享,因此对于一个类的所有实例来说,静态变量是可见的。但是这不代表多线程环境下的可见性!为了确保多线程环境下的可见性,需要使用 volatile 修饰符。volatile 修饰的静态变量具有可见性,因为当一个线程修改该变量时,其他线程可以立即看到修改后的值。

  • 有序性:Java 内存模型确保了在单线程中的程序顺序性,但在多线程环境下,不同线程可能会看到不同的执行顺序。为了确保有序性,可以使用 synchronized 关键字或者 java.util.concurrent 包中的锁机制。

  • 部分原子性:静态变量的操作,如单赋值[不涉及创建+赋值]和读取,通常是原子性的。但是,复合操作(例如自增)不具有原子性。在多线程环境下,为了确保原子性,您可以使用 synchronized 关键字或者 java.util.concurrent.atomic 包中的原子类(例如 AtomicInteger)。

注意: static 修饰的变量本身不具备可见性、有序性和原子性。为了确保这些特性,需要结合使用其他关键字和工具,如 volatilesynchronized 或者 java.util.concurrentjava.util.concurrent.atomic 包中的类。

四、实际使用

1、volatile

在实际使用中volatile大部分用于保证程序的有序性和可见性

有序性使用:单例模式中的双重校验模式 (参考: 有序性)

部分原子性使用:(参考: 可见性)

可见性使用:多线程环境下对变量的读写立即可见 (参考: 可见性)

2、synchronized、Lock

在实际使用中这两种都用于保证组合操作的原子性

  • 使用 synchronized:
public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

在这个示例中,我们使用 synchronized 关键字对 increment()getCount() 方法进行同步化,以保证每次只有一个线程能够访问这些方法,从而保证程序的有序性和原子性。同时,由于 count 变量是类变量,因此它具备可见性,即当一个线程修改了 count 变量的值之后,其它线程能够立即看到这个变量的最新值。

  • 使用 Lock:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Counter {
    private int count = 0;
    private Lock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

在这个示例中,我们使用 java.util.concurrent.locks.Lock 接口和 ReentrantLock 类对 increment()getCount() 方法进行同步化,以保证每次只有一个线程能够访问这些方法,从而保证程序的有序性和原子性。与 synchronized 不同的是,使用 Lock 可以更灵活地控制锁的获取和释放,同时也可以实现更细粒度的锁控制。由于 count 变量是类变量,因此它具备可见性,即当一个线程修改了 count 变量的值之后,其它线程能够立即看到这个变量的最新值。

3、java.util.concurrent.atomic

使用 java.util.concurrent.atomic 包下的类可以很方便地实现能够保证程序有序性、原子性、可见性的操作,个示例代码:

import java.util.concurrent.atomic.AtomicInteger;

public class Counter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }
}

在这个示例中,我们使用 java.util.concurrent.atomic.AtomicInteger 类对 count 变量进行同步化,以保证程序的有序性、原子性和可见性。AtomicInteger 类提供了一系列原子操作方法,例如 incrementAndGet()get() 方法,可以保证多线程环境下对 count 变量的操作是原子性的,并且可以保证线程间对变量的修改具有可见性。由于 count 变量是类变量,因此它具备可见性,即当一个线程修改了 count 变量的值之后,其它线程能够立即看到这个变量的最新值。

使用 java.util.concurrent.atomic 包下的类可以很方便地实现线程安全的操作,同时也可以避免使用 synchronized 和 Lock 等同步机制所带来的性能开销

4、static

在Java中,使用 static 关键字可以创建静态变量和方法。静态变量和方法属于类而不是对象,因此它们在内存中只有一份副本,并且可供所有实例访问。

但在多线程编程中,我们可以使用 static 变量和方法来无法确保程序的有序性、原子性和可见性,故常见操作是通过volatile、synchronized等方式实现,示例如下:

public class Counter {
    private static volatile int count = 0;

    public static synchronized void increment() {
        count++;
    }

    public static synchronized int getCount() {
        return count;
    }
}

在这个示例中,我们使用 static 关键字将 count 变量设置为类变量,从而保证该变量在整个类范围内是唯一的。同时,我们还使用 volatile 关键字对 count 变量进行修饰,以保证线程间对变量的修改具有可见性。

increment()getCount() 方法中,我们使用 synchronized 关键字对这些方法进行同步化,以保证每次只有一个线程能够访问这些方法,从而保证程序的有序性和原子性。由于 count 变量是类变量,因此它具备可见性,即当一个线程修改了 count 变量的值之后,其它线程能够立即看到这个变量的最新值。

使用 static 关键字可以方便地将变量和方法绑定在类级别上,从而实现对变量和方法的全局同步,同时也能够保证线程间对变量的修改具有可见性。但是,使用 static 关键字也可能会带来一些问题,例如可能会增加内存消耗、降低程序的可扩展性等。因此,在使用 static 关键字时需要注意权衡其优缺点,选择合适的方案。

你可能感兴趣的:(分布式系统,Java,大数据,jvm,java,算法,并发,大数据)