1.volatile
保证可见性:当一个变量被声明为`volatile`,编译器和运行时都会注意到这个变量是共享的,并且每次使用这个变量时都必须从主内存中读取,而不是从线程的本地缓存或者寄存器中读取。这确保了所有线程看到的变量值都是最新的。
- 重排序不会对存在数据依赖关系的操作进行重排序。比如:
a=1;b=a;
这个指令序列,因为第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。- 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。比如:
a=1;b=2;c=a+b
这三个操作,第一步 (a=1) 和第二步 (b=2) 由于不存在数据依赖关系,所以可能会发生重排序,但是 c=a+b 这个操作是不会被重排序的,因为需要保证最终的结果一定是 c=a+b=3。
使用 volatile 关键字修饰共享变量可以禁止这种重排序。怎么做到的呢?
当我们使用 volatile 关键字来修饰一个变量时,Java 内存模型会插入内存屏障(一个处理器指令,可以对 CPU 或编译器重排序做出约束)来确保以下两点:
- 写屏障(Write Barrier):当一个 volatile 变量被写入时,写屏障确保在该屏障之前的所有变量的写入操作都提交到主内存。
- 读屏障(Read Barrier):当读取一个 volatile 变量时,读屏障确保在该屏障之后的所有读操作都从主内存中读取。
总的来说:确保在读取`volatile`变量之前,所有之前的操作都已经完成,并且在写入`volatile`变量之后,所有后续的操作都还没有开始。
先看下面未使用 volatile 的代码:
class ReorderExample {
int a = 0;
boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
Public void reader() {
if (flag) { //3
int i = a * a; //4
System.out.println(i);
}
}
}
因为重排序影响,所以最终的输出可能是 0,重排序请参考上一篇 JMM 的介绍,如果引入 volatile,我们再看一下代码:
class ReorderExample {
int a = 0;
boolean volatile flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
Public void reader() {
if (flag) { //3
int i = a * a; //4
System.out.println(i);
}
}
}
这时候,volatile 会禁止指令重排序,这个过程建立在 happens before 关系(上一篇介绍过了)的基础上:
- 根据程序次序规则,1 happens before 2; 3 happens before 4。
- 根据 volatile 规则,2 happens before 3。
- 根据 happens before 的传递性规则,1 happens before 4。
上述 happens before 关系的图形化表现形式如下:
因为以上规则,当线程 A 将 volatile 变量 flag 更改为 true 后,线程 B 能够迅速感知。
volatile不适用的场景
class Counter {
private volatile int count = 0;
public void increment() {
count++; // 非原子操作
}
}
// 问题:多个线程同时调用 increment 方法时,count 的值可能不会正确递增。
为什么不使用?
1. volatile 的作用:
- 当一个字段被声明为 volatile,编译器和运行时都会注意到这个变量是共享的,并且会确保对该变量的读写操作直接作用于主内存,而不是线程的工作内存。这确保了所有线程看到这个变量的最新值。
2. 原子性要求:
- 原子性要求操作是不可中断的,即在操作执行期间,没有其他线程可以插入其他操作。
3. 复合操作的分解:
- 复合操作,如自增(i++),实际上是由多个步骤组成的:
- 读取变量的当前值(Load)
- 在当前值的基础上进行操作(如加1)
- 将结果写回变量(Store)
4. volatile 的限制:
- volatile 只能保证单个操作的原子性。对于读取(Load)和写入(Store)操作,volatile 可以保证它们是原子的,但不能保证复合操作的原子性。
2.synchronized
synchronized确保在多线程环境下共享资源的访问安全。它可以确保同一时刻只有一个线程能够执行特定的代码段,从而避免并发问题,如数据竞争和不一致性。
先了解锁的概念:
锁是一种同步机制,用于控制对共享资源的访问,确保了一次只有一个线程可以访问共享资源,从而避免竞争条件。这里的锁代表着class对象,这意味着同一个时间只有一个线程可以执行该类的所有同步静态方法。
synchronized的同步方法
通过在方法声明中加入 synchronized 关键字,可以保证在任意时刻,只有一个线程能执行该方法。
代码演示:
public class AccountingSync implements Runnable {
//共享资源(临界资源)
static int i = 0;
// synchronized 同步方法
public synchronized void increase() {
i ++;
}
@Override
public void run() {
for(int j=0;j<1000000;j++){
increase();
}
}
public static void main(String args[]) throws InterruptedException {
AccountingSync instance = new AccountingSync();
Thread t1 = new Thread(instance);
Thread t2 = new Thread(instance);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("static, i output:" + i);
}
}
/**
* 输出结果:
* static, i output:2000000
*/
在这个例子中,increment是一个同步的静态方法,它使用类的class对象作为锁。因此,无论increment方法被哪个类的实例调用,或者直接通过类名调用,同一时间只有一个线程可以执行这个方法。
- 避免数据竞争:在多线程程序中,如果多个线程尝试同时修改同一个变量,可能会发生数据竞争,导致不可预测的结果。在这个例子中,由于increase方法是同步的,它避免了两个线程同时修改i的情况。
- 提高性能:虽然同步可能会降低性能,因为它限制了并发性,但在这个特定的例子中,同步是必要的,以确保i的值是准确的。如果没有同步,两个线程可能会同时读取并更新i的值,导致最终结果比预期的2000000要小。
为什么能让它能具有原子性?
由于increase方法是同步的,对变量i的增加操作(i++)变成了一个院子操作,原子操作是指在多线程环境中,这个操作要么完全执行,要么完全不执行,不会出现中间状态会被其他线程观察到的情况
注意:一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,所以无法访问该对象的其他 synchronized 方法,但是其他线程还是可以访问该对象的其他非 synchronized 方法。
但是,如果一个线程 A 需要访问对象 obj1 的 synchronized 方法 f1(当前对象锁是 obj1),另一个线程 B 需要访问对象 obj2 的 synchronized 方法 f2(当前对象锁是 obj2),这样是允许的:
public class AccountingSyncBad implements Runnable {
//共享资源(临界资源)
static int i = 0;
// synchronized 同步方法
public synchronized void increase() {
i ++;
}
@Override
public void run() {
for(int j=0;j<1000000;j++){
increase();
}
}
public static void main(String args[]) throws InterruptedException {
// new 两个AccountingSync新实例
Thread t1 = new Thread(new AccountingSyncBad());
Thread t2 = new Thread(new AccountingSyncBad());
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("static, i output:" + i);
}
}
/**
* 输出结果:
* static, i output:1224617
*/
上述代码与前面不同的是,我们创建了两个对象 AccountingSyncBad,然后启动两个不同的线程对共享变量 i 进行操作,但很遗憾,操作结果是 1224617 而不是期望的结果 2000000。
因为上述代码犯了严重的错误,虽然使用了 synchronized 同步 increase 方法,但却 new 了两个不同的对象,这也就意味着存在着两个不同的对象锁,因此 t1 和 t2 都会进入各自的对象锁,也就是说 t1 和 t2 线程使用的是不同的锁,因此线程安全是无法保证的。
每个对象都有一个对象锁,不同的对象,他们的锁不会互相影响。
解决这种问题的的方式是将 synchronized 作用于静态的 increase 方法,这样的话,对象锁就锁的是当前的类,由于无论创建多少个对象,类永远只有一个,所有在这样的情况下对象锁就是唯一的。
synchronized同步静态方法
当 synchronized 同步静态方法时,锁的是当前类的 Class 对象,不属于某个对象。当前类的 Class 对象锁被获取,不影响实例对象锁的获取,两者互不影响,本质上是 this 和 Class 的不同。
- 使用this作为锁:当使用实例方法中的this作为锁时,锁定的是当前实例对象,这意味着,同一时间只有一个线程可以执行同一个实例的所有同步实例方法,不同的实例之间不会互相阻塞对方的同步实例。
- 使用class作为锁: class对象作为锁时,作用范围是整个类的所有实例。这意味着任何实例的静态方法执行时,都会阻塞其他实例的同步静态方法
需要注意的是如果线程 A 调用了一个对象的非静态 synchronized 方法,线程 B 需要调用这个对象所属类的静态 synchronized 方法,是不会发生互斥的,因为访问静态 synchronized 方法占用的锁是当前类的 Class 对象,而访问非静态 synchronized 方法占用的锁是当前对象(this)的锁,看如下代码:
public class AccountingSyncClass implements Runnable {
static int i = 0;
/**
* 同步静态方法,锁是当前class对象,也就是
* AccountingSyncClass类对应的class对象
*/
public static synchronized void increase() {
i++;
}
// 非静态,访问时锁不一样不会发生互斥
public synchronized void increase4Obj() {
i++;
}
@Override
public void run() {
for(int j=0;j<1000000;j++){
increase();
}
}
public static void main(String[] args) throws InterruptedException {
//new新实例
Thread t1=new Thread(new AccountingSyncClass());
//new新实例
Thread t2=new Thread(new AccountingSyncClass());
//启动线程
t1.start();t2.start();
t1.join();t2.join();
System.out.println(i);
}
}
/**
* 输出结果:
* 2000000
*/
由于 synchronized 关键字同步的是静态的 increase 方法,与同步实例方法不同的是,其锁对象是当前类的 Class 对象。
注意代码中的 increase4Obj 方法是实例方法,其对象锁是当前实例对象(this),如果别的线程调用该方法,将不会产生互斥现象,毕竟锁的对象不同,这种情况下可能会发生线程安全问题(操作了共享静态变量 i)。
synchronized同步代码块
某些情况下,我们编写的方法代码量比较多,存在一些比较耗时的操作,而需要同步的代码块只有一小部分,如果直接对整个方法进行同步,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹。
public class AccountingSync2 implements Runnable {
static AccountingSync2 instance = new AccountingSync2(); // 饿汉单例模式
static int i=0;
@Override
public void run() {
//省略其他耗时操作....
//使用同步代码块对变量i进行同步操作,锁对象为instance
synchronized(instance){
for(int j=0;j<1000000;j++){
i++;
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();t2.start();
t1.join();t2.join();
System.out.println(i);
}
}
首先是锁对象的选择:在同步代码块中,锁对象是AccountingSync2类的一个静态实例instance。这就意味着所有需要修改共享资源i的线程都必须首先获得这个实例对象的锁。
线程安全:通过使用同步代码块,确保了共享资源i的访问是线程安全的。即使两个线程t1,t2都使用了同一个Runnable实例它们在增加i时也是互斥的,因为他们需要依次获得instance对象的锁。
我们将 synchronized 作用于一个给定的实例对象 instance,即当前实例对象就是锁的对象,当线程进入 synchronized 包裹的代码块时就会要求当前线程持有 instance 实例对象的锁,如果当前有其他线程正持有该对象锁,那么新的线程就必须等待,这样就保证了每次只有一个线程执行
i++
操作。
当然除了用 instance 作为对象外,我们还可以使用 this 对象(代表当前实例)或者当前类的 Class 对象作为锁,如下代码:
//this,当前实例对象锁
synchronized(this){
for(int j=0;j<1000000;j++){
i++;
}
}
//Class对象锁
synchronized(AccountingSync.class){
for(int j=0;j<1000000;j++){
i++;
}
}
synchronized与happens before
监视锁是一种同步机制,用于控制对共享资源的访问,确保在同一时间只有一个线程可以访问特定的代码段。监视锁通常与synchronized关键字一起使用。
class MonitorExample {
int a = 0;
public synchronized void writer() { //1
a++; //2
} //3
public synchronized void reader() { //4
int i = a; //5
//……
} //6
}
- 1. 同步方法:writer() 和 reader() 都是同步方法,这意味着它们各自拥有一个锁,并且一次只有一个线程可以执行这些方法中的任何一个。
- 2. 锁的范围:对于同步方法,锁的范围是当前对象实例(this)。这意味着每个 MonitorExample 实例都有自己的锁。
- 3. 原子性:在 writer() 方法中,a++(行2)是一个复合操作,它包括获取 a 的值、增加 1 和存储结果。由于 writer() 是同步的,这个复合操作是原子性的,即在执行过程中不会被其他线程中断。
- 4. 可见性:由于 writer() 是同步方法,对 a 的修改对其他线程是可见的。当一个线程执行 writer() 并修改了 a 的值后,释放锁时,这个修改对其他线程立即可见。
- 5. 互斥性:reader() 方法(行4-6)也是同步的,这意味着如果一个线程正在执行 reader() 读取 a 的值,其他线程必须等待直到锁被释放才能执行 writer() 或另一个 reader()。
- 根据程序次序规则,1 happens before 2, 2 happens before 3; 4 happens before 5, 5 happens before 6。
- 根据监视器锁规则,3 happens before 4。
- 根据 happens before 的传递性,2 happens before 5。
在 Java 内存模型中,监视器锁规则是一种 happens-before 规则,它规定了对一个监视器锁(monitor lock)或者叫做互斥锁的解锁操作 happens-before 于随后对这个锁的加锁操作。简单来说,这意味着在一个线程释放某个锁之后,另一个线程获得同一把锁的时候,前一个线程在释放锁时所做的所有修改对后一个线程都是可见的。
在上图中,每一个箭头链接的两个节点,代表了一个 happens before 关系。黑色箭头表示程序顺序规则;橙色箭头表示监视器锁规则;蓝色箭头表示组合这些规则后提供的 happens before 保证。
上图表示在线程 A 释放了锁之后,随后线程 B 获取同一个锁。在上图中,2 happens before 5。因此,线程 A 在释放锁之前所有可见的共享变量,在线程 B 获取同一个锁之后,将立刻变得对 B 线程可见。
也就是说,synchronized 会防止临界区内的代码与外部代码发生重排序,writer()
方法中 a++ 的执行和 reader()
方法中 a 的读取之间存在 happens-before 关系,保证了执行顺序和内存可见性。
synchronized属于可重入锁
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功。
synchronized 就是可重入锁,因此一个线程调用 synchronized 方法的同时,在其方法体内部调用该对象另一个 synchronized 方法是允许的,如下:
public class AccountingSync implements Runnable{
static AccountingSync instance=new AccountingSync();
static int i=0;
static int j=0;
@Override
public void run() {
for(int j=0;j<1000000;j++){
//this,当前实例对象锁
synchronized(this){
i++;
increase();//synchronized的可重入性
}
}
}
public synchronized void increase(){
j++;
}
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(instance);
Thread t2=new Thread(instance);
t1.start();t2.start();
t1.join();t2.join();
System.out.println(i);
}}
1、AccountingSync 类中定义了一个静态的 AccountingSync 实例 instance 和两个静态的整数 i 和 j,静态变量被所有的对象所共享。
2、在 run 方法中,使用了
synchronized(this)
来加锁。这里的锁对象是 this,即当前的 AccountingSync 实例。在锁定的代码块中,对静态变量 i 进行增加,并调用了 increase 方法。3、increase 方法是一个同步方法,它会对 j 进行增加。由于 increase 方法也是同步的,所以它能在已经获取到锁的情况下被 run 方法调用,这就是 synchronized 关键字的可重入性。
4、在 main 方法中,创建了两个线程 t1 和 t2,它们共享同一个 Runnable 对象,也就是共享同一个 AccountingSync 实例。然后启动这两个线程,并使用 join 方法等待它们都执行完成后,打印 i 的值。
此程序中的
synchronized(this)
和 synchronized 方法都使用了同一个锁对象(当前的 AccountingSync 实例),并且对静态变量 i 和 j 进行了增加操作,因此,在多线程环境下,也能保证 i 和 j 的操作是线程安全的。