一、介绍
1、什么是可见性、原子性、有序性?
- 可见性(visibility):指一个线程对共享变量的修改能够被其他线程立即看到的特性。在多线程环境下,如果一个线程修改了一个共享变量的值,那么其他线程可能无法立即看到这个修改,因为线程之间有可能存在缓存不一致的问题。为了保证可见性,可以使用volatile关键字或者显式地使用锁来实现。
- 原子性(atomicity):指一个操作是不可分割的、完整的,要么全部执行成功,要么全部不执行,不存在执行一半的情况。在多线程环境下,如果一个操作不是原子性的,那么可能会发生竞态条件(race condition)等问题,导致程序出现不可预期的错误。为了保证原子性,可以使用synchronized关键字或者使用Atomic类中提供的原子操作。
例如:在java中count++ 与 Person person = new Person()就不具备原子性,因其在JVM中会变成多个指令顺序执行
- 有序性(ordering):指程序执行的顺序必须符合预期,不能出现乱序的情况。在多线程环境下,由于编译器、处理器、缓存等因素的影响,程序执行的顺序可能会出现不一致的情况,导致程序出现错误。为了保证有序性,可以使用volatile关键字或者显式地使用锁来实现。同时,Java提供了happens-before规则,它可以保证在特定情况下,操作的顺序是按照预期的顺序执行的。
2、举例说明
1、可见性
注意到HelloThread
的标志位boolean running
是一个线程间共享的变量。线程间共享变量需要使用volatile
关键字标记,确保每个线程都能读取到更新后的变量值。
为什么要对线程间共享的变量用关键字volatile
声明?这涉及到Java的内存模型。在Java虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!
这会导致如果一个线程更新了某个变量,另一个线程读取的值可能还是更新前的。例如,主内存的变量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的整数之和
sum是一个局部变量,它只在calcSum方法内部可见。在多线程环境下,每个线程都会拥有自己的执行栈和局部变量表,因此不会出现线程间共享变量的情况。每个线程都可以独立的执行calcSum方法,不会互相影响,因此该方法是线程安全的。
需要注意的是,如果方法中使用了共享变量(比如类的成员变量或静态变量),那么就需要进行线程同步操作,以保证线程安全性。
2.2、单原子操作
JVM规范定义了几种原子操作:
- 基本类型(
long
和double
除外)赋值,例如:int n = m
; - 引用类型赋值,例如:
List<String> list = anotherList
。
不具备原子性的操作:
2.3、共享变量
在这个示例代码中,increment
和 decrement
方法对共享变量 count
进行递增和递减操作,但这些操作不具备原子性。如果多个线程同时调用这些方法,可能会出现竞态条件(race condition)导致计数器的值出现错误。
2.4、复合操作
代码中的 getPerson
方法是线程不安全的,因为它对共享变量 person
进行了非原子性的读写操作。
多个线程同时调用 getPerson
方法时,可能会出现竞态条件(race condition),导致 person
变量的值出现错误。例如,一个线程在执行 person = new Person(name, age)
语句时,另一个线程可能会读取到 person
变量的旧值,导致返回的 Person
对象不是最新创建的对象。
3、有序性
单例模式中双重校验的使用
在上面的代码中,volatile变量是为了解决程序的重排序问题,原因如下:
uniqueInstance = new Singleton()
这行代码并不是一个原子指令。使用 javap -c
指令,可以快速查看字节码。
从字节码可以看到创建一个对象实例,可以分为三步:
- 分配对象内存
- 调用构造器方法,执行初始化
- 将对象引用赋值给变量。
虚拟机实际运行时,以上指令可能发生重排序。以上代码 2,3 可能发生重排序,但是并不会重排序 1 的顺序,排序后的顺序如下:
- 分配对象内存
- 将对象引用赋值给变量**(此时uniqueInstance就不等于null了,但uniqueInstance并没有初始化!)**
- 调用构造器方法,执行初始化
如果出现重排序问题,此时变量也没有使用volatile修饰,那么该双重校验模式会出现异常,如下图:
故正确的双重检查锁定模式需要需要使用 volatile
。volatile
主要包含两个功能
- 保证可见性。使用
volatile
定义的变量,将会保证对所有线程的可见性。 - 禁止指令重排序优化。
由于 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具备可见性、有序性、原子性
synchronized
和 Lock
锁都具备可见性、有序性和原子性。
- 可见性:对于
synchronized
和Lock
锁而言,都能够保证在一个线程修改了共享变量的值之后,其它线程能够立即看到这个变量的最新值。在synchronized
中,进入和退出同步块时,会自动执行锁的获取和释放操作,这些操作会将本地缓存中的数据刷新到主内存中,从而保证了可见性。在Lock
锁中,当一个线程获取锁时,它会读取主内存中的最新值,将其存储到本地内存中,当它释放锁时,会将本地内存中的数据刷新到主内存中,从而保证了可见性。 - 有序性:对于
synchronized
和Lock
锁而言,都能够保证在一个线程执行完毕后,其它线程才能执行被锁保护的代码块或方法。在synchronized
中,对于同一个锁对象,同一时间只有一个线程能够执行被锁保护的代码块或方法,其它线程需要等待锁的释放,从而保证了执行的有序性。在Lock
锁中,当一个线程获取锁时,其它线程需要等待锁的释放,从而保证了执行的有序性。 - 原子性:对于
synchronized
和Lock
锁而言,都能够保证其中的操作是原子性的。当一个线程获得了锁,进入了被锁保护的代码块或方法,其它线程需要等待,直到这个线程执行完毕,释放锁之后,其它线程才能进入被锁保护的代码块或方法。这样就可以保证其中的操作是原子性的,避免了多个线程同时修改共享变量的值,导致数据出现错误的情况。
注意:在使用
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
包下的类,例如AtomicInteger
、AtomicLong
等,都使用了 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
修饰的变量本身不具备可见性、有序性和原子性。为了确保这些特性,需要结合使用其他关键字和工具,如volatile
、synchronized
或者java.util.concurrent
和java.util.concurrent.atomic
包中的类。
四、实际使用
1、volatile
在实际使用中volatile大部分用于保证程序的有序性和可见性
有序性使用:单例模式中的双重校验模式 (参考: 有序性)
部分原子性使用:(参考: 可见性)
可见性使用:多线程环境下对变量的读写立即可见 (参考: 可见性)
2、synchronized、Lock
在实际使用中这两种都用于保证组合操作的原子性
- 使用 synchronized:
在这个示例中,我们使用 synchronized
关键字对 increment()
和 getCount()
方法进行同步化,以保证每次只有一个线程能够访问这些方法,从而保证程序的有序性和原子性。同时,由于 count
变量是类变量,因此它具备可见性,即当一个线程修改了 count
变量的值之后,其它线程能够立即看到这个变量的最新值。
- 使用 Lock:
在这个示例中,我们使用 java.util.concurrent.locks.Lock
接口和 ReentrantLock
类对 increment()
和 getCount()
方法进行同步化,以保证每次只有一个线程能够访问这些方法,从而保证程序的有序性和原子性。与 synchronized 不同的是,使用 Lock 可以更灵活地控制锁的获取和释放,同时也可以实现更细粒度的锁控制。由于 count
变量是类变量,因此它具备可见性,即当一个线程修改了 count
变量的值之后,其它线程能够立即看到这个变量的最新值。
3、java.util.concurrent.atomic
使用 java.util.concurrent.atomic
包下的类可以很方便地实现能够保证程序有序性、原子性、可见性的操作,个示例代码:
在这个示例中,我们使用 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等方式实现,示例如下:
在这个示例中,我们使用 static
关键字将 count
变量设置为类变量,从而保证该变量在整个类范围内是唯一的。同时,我们还使用 volatile
关键字对 count
变量进行修饰,以保证线程间对变量的修改具有可见性。
在 increment()
和 getCount()
方法中,我们使用 synchronized
关键字对这些方法进行同步化,以保证每次只有一个线程能够访问这些方法,从而保证程序的有序性和原子性。由于 count
变量是类变量,因此它具备可见性,即当一个线程修改了 count
变量的值之后,其它线程能够立即看到这个变量的最新值。
使用 static
关键字可以方便地将变量和方法绑定在类级别上,从而实现对变量和方法的全局同步,同时也能够保证线程间对变量的修改具有可见性。但是,使用 static
关键字也可能会带来一些问题,例如可能会增加内存消耗、降低程序的可扩展性等。因此,在使用 static
关键字时需要注意权衡其优缺点,选择合适的方案。