培训教育类网站模板网络营销制度课完整版
一、volatile
(1)、简述
volatile是java提供的一个关键字,英文意思为不稳定的。
可以保障被声明对象的可见性和一定程度上的有序性,但不能保证操作的原子性。
当一个变量被声明为volatile时,意味着该变量的值会直接从主内存中读取,并且对该变量的任何写操作都会立即写回主内存,并通知给其他引用该变量的线程。
(2)、volatile不保证原子性怎么理解?
volatile关键字只能确保单个步骤变量的读写操作是原子的,但不能保证复合操作的原子性。例如,i++操作实际上是三个步骤(读取、加1、写回),即使i被声明为volatile,这三个步骤仍然是可以被中断的。
简单说:在多线程场景下,volatile变量被执行的操作是多步骤的(如:i++),其他线程也刚好读i这个数据,可能会出现读写不一致的问题。
(3)、不保证原子性,为啥还要用它?
volatile是轻量级的同步机制,对性能的影响比synchronized小。(性能还不错)
典型的用法:检查某个状态标记以判断是否退出循环。(主要用于循环判断退出,这种场景还不错,不要用过复杂场景)
那为什么我们不直接用synchorized,lock锁?它们既可以保证可见性,又能保证原子性啊?
因为synchorized和lock是排他锁(悲观锁),如果有多个线程需要访问这个变量,将会发生竞争,只有一个线程可以访问这个变量,其他线程被阻塞了,会影响程序的性能。(多线程时性能差啊)
(4)、使用volatile的条件
1、对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。(如:用于读多写少的场景,或写时避免出现i++多步骤操作的场景)
2、该变量不会与其他的状态一起纳入不变性条件中。(如果volatile值用于判断,且对结果不会产生改变的话,对程序就没有了意义)
3、在访问变量时不需要加锁。(都使用锁了,还要这个干嘛)
(5)、怎么理解volatile是有序性的呢?
volatile关键字可以防止指令重排序。编译器和处理器在编译和执行过程中可能会对指令进行重排序以优化性能,但这些重排序可能会导致并发问题。volatile变量的读写操作会产生内存屏障(Memory Barrier),确保在读取或写入volatile变量之前/之后的指令不会被重排序。
(6)、内存屏障
内存屏障(Memory Barrier,也称为内存栅栏或内存 fence)是计算机体系结构和编程语言中的一种同步机制,用于确保特定的内存操作按指定的顺序执行。内存屏障主要用于防止编译器和处理器对指令进行重排序,从而保证程序的正确性和一致性。
在附录模块,在重点介绍下内存屏障。
(7)、线程如何感应到volatile变量更新?
1、缓存一致性理解
当多个CPU持有的变量副本都来自同一个主内存的拷贝时,如果某个CPU改了这个主内存变量值后,其他CPU并不知道,那拷贝的内存将会和主内存不一致,这就是缓存不一致。如果其他CPU都知道的话,那拷贝的内存将会和主内存一致,也就是缓存一致了。
如下图所示,CPU2 偷偷将num修改为2,内存中num也被修改为2,但是CPU1和CPU3并不知道num值变了。
2、怎么保证缓存一致性呢?
实现缓存一致性的原理就是MESI协议和窥探(snooping)协议,这个在附录中在详细说明。
二、synchronized
(1)、概述
Synchronized是Java中用于实现线程同步的关键字。它通过确保同一时间只有一个线程可以访问某个资源来避免多线程环境下的竞态条件和数据不一致问题。Synchronized主要通过锁机制来实现这一点。
(2)、实现原理
1、锁机制
- Synchronized关键字会在进入同步代码块或同步方法时获取锁,在退出同步代码块或同步方法时释放锁。
- 锁可以是对象锁或类锁(静态方法锁)。
即:代码运行时上锁,结束后释放锁,保证同一时刻只能让一个线程运行。
2、锁分类
(1)、对象锁
- 对于非静态方法或同步代码块,锁是对象的实例锁。
- 每个对象都有一个内置的锁(也称为监视器锁或monitor lock)。
- 同一时间只能有一个线程可以持有该对象的锁。
即:一个对象中存在多个Synchronized的方法,多个线程访问该类的不同的同步方法时,也是只有一个线程会运行,其他线程被阻塞,因为同步方法的锁就是对象本身。
代码示例:
public class MyClass {private int count = 0;public synchronized void increment() {count++;}public static void main(String[] args) {MyClass obj = new MyClass();Thread t1 = new Thread(() -> obj.increment());Thread t2 = new Thread(() -> obj.increment());t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println(obj.count); // 输出 2}
}
(2)、类锁
- 对于静态方法或同步代码块,锁是类的锁。
- 类锁是针对类的Class对象的锁。
- 同一时间只能有一个线程可以持有该类的锁。
即:被static修饰的synchronized 方法,所有使用该方法的线程,都会被上锁
代码示例:
public class MyClass {private static int count = 0;public static synchronized void increment() {count++;}public static void main(String[] args) {Thread t1 = new Thread(() -> MyClass.increment());Thread t2 = new Thread(() -> MyClass.increment());t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println(MyClass.count); // 输出 2}
}
3、锁的获取和释放
- 当一个线程尝试进入一个被synchronized保护的代码块或方法时,它会尝试获取锁。
- 如果锁已被其他线程持有,当前线程会被阻塞,直到锁可用。
- 当线程退出同步代码块或方法时,会自动释放锁。
(3)、适用场景
1、资源共享
- 当多个线程需要访问和修改同一个资源(如变量、对象或文件)时,使用synchronized可以确保数据的一致性和完整性。
- 例如,多个线程对同一个计数器进行增减操作。
2、线程安全的单例模式 - 使用synchronized可以确保在多线程环境下,单例模式的实例只会被创建一次。
- 例如,懒汉式单例模式。
3、避免竞态条件 - 当多个线程对同一个变量进行读写操作时,可能会导致竞态条件,使用synchronized可以避免这种情况。
- 例如,银行账户的转账操作。
4、线程间通信 - synchronized可以用于实现线程间的通信,通过等待和通知机制来协调线程的行为。
- 例如,生产者-消费者模型。
(4)、产生的问题
1、性能开销
- synchronized会带来一定的性能开销,因为它会导致线程阻塞和唤醒。在高并发场景下,可以考虑使用更细粒度的锁或无锁算法来提高性能。如,使用
ReentrantLock
或Atomic
类。
2、死锁 - 使用
synchronized
时需要注意避免死锁。死锁通常发生在多个线程互相等待对方释放锁的情况下。 - 例如,两个线程分别持有不同的锁,但又试图获取对方持有的锁。
3、锁的粒度 - 尽量使用细粒度的锁,只锁定必要的部分,以减少锁的竞争和提高并发性能。
- 例如,上锁的代码越少越好,只锁最重点的部分;锁定一个特定的对象而不是整个方法。
(5)、Synchronized总结
Synchronized是Java中实现线程同步的一种简单而强大的机制。它通过锁机制确保在同一时间只有一个线程可以访问被保护的资源,适用于多种需要线程安全的场景。然而,在高并发场景下,需要权衡性能和安全性,选择合适的同步机制。
(6)、Synchronized实现内存模型三大特性
1、实现原子性
实现原理
- synchronized关键字确保同一时间只有一个线程可以进入被同步的代码块或方法。
- 这样可以防止多个线程同时执行相同的代码段,从而确保操作的原子性。
代码示例:
public class AtomicityExample {private int counter = 0;public synchronized void increment() {int temp = counter; // 读取temp += 1; // 修改counter = temp; // 写回}public static void main(String[] args) {AtomicityExample example = new AtomicityExample();Thread t1 = new Thread(() -> {for (int i = 0; i < 1000; i++) {example.increment();}});Thread t2 = new Thread(() -> {for (int i = 0; i < 1000; i++) {example.increment();}});t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println("Final counter value: " + example.counter); // 输出 2000}
}
2、实现可见性
实现原理
- 当一个线程释放锁时,它会将本地内存中的所有变量值刷新回主内存。
- 当另一个线程获取同一个锁时,它会从主内存中读取最新的变量值。
即:调用synchronized 方法时,对象锁(即如下的example实例)会被从主内存加载后,完成上锁;方法执行结束,会将对象锁重新刷回到主内存中,在释放锁。
代码示例
public class VisibilityExample {private int value;public synchronized void writeValue(int newValue) {value = newValue; // 写操作}public synchronized int readValue() {return value; // 读操作}public static void main(String[] args) {VisibilityExample example = new VisibilityExample(); // 对象锁,synchronized执行前后都会进行一次线程变量副本和主内存的数据同步。Thread writer = new Thread(() -> {example.writeValue(10);});Thread reader = new Thread(() -> {while (example.readValue() == 0) {// 等待直到value被修改}System.out.println("Value is now: " + example.readValue());});writer.start();reader.start();}
}
解释:
writeValue
方法和readValue
方法都被声明为synchronized
,这意味着它们在执行时会获取同一个对象锁。- 当
writer
线程调用writeValue
方法并修改value
时,它会释放锁并将value
的最新值刷新回主内存。 - 当
reader
线程调用readValue
方法时,它会获取锁并从主内存中读取最新的value
值。
3、实现有序性
实现原理
- synchronized关键字通过内存屏障(Memory Barrier)确保指令不会被重排序。
- 当一个线程释放锁时,它会插入一个内存屏障,确保之前的所有写操作都已完成并可见。
- 当另一个线程获取锁时,它也会插入一个内存屏障,确保之后的所有读操作都能看到之前的写操作。
代码示例
public class OrderingExample {private int a = 0;private boolean flag = false;public synchronized void writer() {a = 1; // 写操作flag = true; // 设置标志}public synchronized void reader() {if (flag) {System.out.println("a = " + a); // 应该总是输出 a = 1}}public static void main(String[] args) {OrderingExample example = new OrderingExample();Thread writerThread = new Thread(() -> {example.writer();});Thread readerThread = new Thread(() -> {example.reader();});writerThread.start();readerThread.start();try {writerThread.join();readerThread.join();} catch (InterruptedException e) {e.printStackTrace();}}
}
解释:
writer
方法和reader
方法都被声明为synchronized
,确保它们在同一时间只能由一个线程执行。writer
方法先写入a
,然后设置flag
。reader
方法在检查flag
时,如果flag
为true
,则读取a
的值。- 由于
synchronized
确保了有序性,reader
方法在读取a
时一定会看到a
的最新值(即1)。
三、附录
附1:内存屏障
1、概念
内存屏障(Memory Barrier,也称为内存栅栏或内存 fence)是计算机体系结构和编程语言中的一种同步机制,用于确保特定的内存操作按指定的顺序执行。内存屏障主要用于防止编译器和处理器对指令进行重排序,从而保证程序的正确性和一致性。
2、内存屏障的作用
(1)、防止指令重排序
编译器和处理器为了优化性能,可能会对指令进行重排序。内存屏障可以确保在屏障之前的指令在屏障之后的指令之前完成。
(2)、确保内存可见性
内存屏障可以确保一个线程对内存的修改对其他线程是可见的。
3、内存屏障的分类
可分为两大类:读屏障和写屏障
4、写屏障(Store Barrier)
(1)、简述
当一个线程写入一个volatile变量时,Java内存模型会在写操作之后插入一个写屏障(Store Barrier)。这个屏障确保了所有在此之前发生的写操作在写入volatile变量之前完成,并且这些写操作的结果对其他线程是可见的。
(2)、实现原理
1、确保之前的写操作完成:
- 在写入volatile变量之前,所有之前的写操作必须完成。
- 编译器和处理器不能将这些写操作重排序到写入volatile变量之后。
2、刷新到主内存:
- 写入volatile变量时,该变量的值会立即刷新到主内存中,而不是仅保存在线程的工作内存中。
- 这确保了其他线程在读取这个volatile变量时,能够看到最新的值。
(3)、代码示例
public class VolatileWriteBarrierExample {private volatile boolean flag = false;private int data = 0;public void writer() {data = 42; // 写入普通变量flag = true; // 写入volatile变量,插入Store Barrier}
}
在这个例子中:
data = 42
是一个普通的写操作。flag = true
是一个写入volatile
变量的操作,会插入一个写屏障。- 写屏障确保了在写入
flag
之前,data = 42
已经完成,并且data
的值已经刷新到主内存中。
5、读屏障(Load Barrier)
(1)、简述
当一个线程读取一个volatile变量时,Java内存模型会在读操作之后插入一个读屏障(Load Barrier)。这个屏障确保了所有在此之后的读操作在读取完成volatile变量之后开始,并且能够看到最新的值。
(2)、实现原理
1、确保之前的读操作完成:
- 在读取volatile变量之前,所有之前的读操作必须完成。
- 编译器和处理器不能将这些读操作重排序到读取volatile变量之后。
2、从主内存中读取:
- 读取volatile变量时,该变量的值会直接从主内存中读取,而不是使用线程工作内存中的副本。
- 这确保了读取到的是最新的值。
3、确保后续的读操作看到最新的值:
- 在读取volatile变量之后,所有后续的读操作必须看到最新的值。
- 编译器和处理器不能将这些读操作重排序到读取volatile变量之前。
(3)、代码示例
public class VolatileReadBarrierExample {private volatile boolean flag = false;private int data = 0;public void reader() {if (flag) { // 读取volatile变量,插入Load Barrierint localData = data; // 读取普通变量// 使用localData}..这里的操作也会等flag获取到值之后才会执行}
}
在这个例子中:
if (flag)
是一个读取volatile
变量的操作,会插入一个读屏障。- 读屏障确保了在读取
flag
之前,所有之前的读操作已经完成,并且flag
的值是从主内存中读取的。 - 在读取
flag
之后,int localData = data
会看到最新的data
值,因为读屏障确保了data
的值已经是最新的。
6、读写屏障总结
(1)、写屏障(Store Barrier)
确保在写入volatile变量之前,所有之前的写操作已经完成,并且这些写操作的结果已经刷新到主内存中。
(2)、读屏障(Load Barrier)
确保在读取volatile变量之后,所有后续的读操作能够看到最新的值,并且这些读操作不会被重排序到读取volatile变量之前。
通过这些屏障,volatile关键字确保了多线程环境下的内存可见性和有序性,从而避免了并发问题。
附2:MESI协议
(1)、概述
MESI协议是一种广泛用于多处理器系统中的缓存一致性协议。它的名字来源于四种缓存行状态:Modified(已修改)、Exclusive(独占)、Shared(共享)、Invalid(无效)。MESI协议通过维护这些状态来确保多个处理器核心之间的缓存数据一致性。
(2)、四种状态解释
1、Modified (已修改)
- 当一个缓存行处于Modified状态时,表示该缓存行的数据已经被修改过,但尚未写回主内存。此时,只有当前处理器持有该缓存行的副本,并且它是唯一的、最新的版本。
- Modified状态下的缓存行被称为“脏”(dirty),因为其内容与主内存中的内容不一致。
2、Exclusive (独占)
- Exclusive状态表示当前处理器独占了该缓存行,但没有对其进行任何修改。这意味着当前处理器是唯一拥有该缓存行副本的处理器,但它还没有对该数据做出任何更改。
- Exclusive状态下的缓存行在被处理器读取后,如果处理器开始对该数据进行写操作,该缓存行将转变为Modified状态。
3、Shared (共享)
- Shared状态表示有多个处理器都持有该缓存行的副本,但没有任何一个处理器对其进行了修改。在这种状态下,所有持有该缓存行副本的处理器都可以安全地读取数据,但不能写入。
- 如果任何一个处理器尝试写入一个处于Shared状态的缓存行,必须先使其他处理器持有的该缓存行副本失效,然后才能转换为Modified或Exclusive状态。
4、Invalid (无效)
- Invalid状态表示当前处理器不持有该缓存行的有效副本。当处理器需要访问一个无效的缓存行时,必须从主内存或其他处理器那里获取最新版本的数据。
(3)、协议运作流程
1、读取请求
当一个处理器尝试读取一个数据项时,它会检查自己的缓存中是否有该数据项的有效副本。如果有并且处于Shared或Exclusive状态,则直接使用;如果是Invalid状态,则需要从其他处理器或主内存中请求最新的数据副本。
2、写入请求
当一个处理器尝试写入一个数据项时,它首先需要确保自己是唯一拥有该数据项的处理器,并且该数据项处于Modified或Exclusive状态。如果数据项处于Shared状态,处理器必须发送消息给其他持有该数据项副本的处理器,要求它们将副本设置为Invalid,然后才能进行写操作。
(4)、MESI协议的优点
1、减少不必要的内存访问
通过缓存一致性协议,减少了处理器对主内存的频繁访问,提高了系统的整体性能。
2、简化了多处理器系统的设计
MESI协议提供了一种简单而有效的方法来管理多处理器环境中的缓存一致性问题。
(5)、示例说明
当CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,系统会发出信号通知其它CPU将该内存变量的缓存行设置为无效。如下图所示,CPU1和CPU3 中num=1已经失效了。
当其它CPU读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。
如下图所示,CPU1和CPU3发现缓存的num值失效了,就重新从内存读取,num值更新为2。
(6)、总线嗅探
那其他CPU是怎么知道要将缓存更新为失效的呢?这里是用到了总线嗅探技术。
每个CPU不断嗅探总线上传播的数据来检查自己缓存值是否过期了,如果处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。
(7)、总线风暴
总线嗅探技术有哪些缺点?
由于MESI缓存一致性协议,需要不断对主线进行内存嗅探,大量的交互会导致总线带宽达到峰值。因此不要滥用volatile,可以用锁来替代,看场景啦~
(8)、窥探(snooping)协议
(1)、概述
“窥探(Snooping)”协议是一种常用的缓存一致性协议,主要用于多处理器系统中,确保各个处理器缓存的数据一致性。在窥探协议中,每个处理器都会监听总线上发生的事务,以确定是否需要更新其缓存中的数据。这种协议通过广播的方式让所有处理器都能及时了解其他处理器的缓存操作,从而保持数据的一致性。
(2)、工作原理
1、总线监听
- 每个处理器不仅处理自己的指令,还会监听总线上发生的其他处理器发出的请求。
- 这些请求包括读请求、写请求等。
2、响应机制 - 当一个处理器发出读请求时,其他处理器会检查自己的缓存,看看是否持有请求的数据项。
- 如果某个处理器持有该数据项的副本,并且该副本是最新的(例如处于Modified或Exclusive状态),则该处理器会响应读请求,并将数据直接发送给请求方。
- 如果请求的数据项在某个处理器的缓存中处于Invalid状态,或者请求的是写操作,处理器会根据具体情况进行相应的处理,比如使其他处理器持有的副本失效。
3、状态更新 - 当一个处理器收到其他处理器的写请求时,如果它持有该数据项的副本,会将副本标记为Invalid,以确保数据的一致性。
- 类似地,当一个处理器收到其他处理器的读请求时,如果它持有该数据项的副本,会根据请求的情况更新缓存行的状态。
(3)、示例说明
假设有一个多处理器系统,包含两个处理器 P1 和 P2,它们都使用窥探协议来维护缓存一致性。
1、初始状态
- P1 缓存中有一个数据项 A,状态为 Exclusive。
- P2 缓存中没有数据项 A。
2、P2 发出读请求
- P2 需要读取数据项 A,于是向总线发出读请求。
- P1 监听到这个读请求,发现自己的缓存中有数据项 A 的副本,状态为 Exclusive。
- P1 将数据项 A 发送给 P2,并将自己缓存中的数据项 A 状态更新为 Shared。
- P2 收到数据项 A,并将其存入自己的缓存中,状态为 Shared。
3、P1 发出写请求
- P1 需要写入数据项 A,于是向总线发出写请求。
- P2 监听到这个写请求,发现自己缓存中有数据项 A 的副本,状态为 Shared。
- P2 将自己缓存中的数据项 A 标记为 Invalid。
- P1 更新自己缓存中的数据项 A,状态变为 Modified。
(4)、优点
1、简单易实现:窥探协议通过简单的总线监听和响应机制,能够有效地维护缓存一致性。
2、低延迟:因为数据可以在处理器之间直接传递,不需要每次都访问主内存,所以可以降低数据访问的延迟。
(5)、缺点
1、总线带宽限制:随着处理器数量的增加,总线上的流量会增加,可能导致总线带宽不足,影响系统性能。
2、功耗较高:每个处理器都需要不断监听总线上的事务,增加了功耗。
总之,窥探协议是一种有效的缓存一致性解决方案,特别适用于处理器数量较少的多处理器系统。在大规模多处理器系统中,可能会采用更复杂的协议来提高性能和效率。
(9)、总结
“窥探(Snooping)”协议和MESI协议实际上是相辅相成的。MESI协议是一种具体的缓存一致性协议,它定义了缓存行的四种状态(Modified、Exclusive、Shared、Invalid),并规定了如何在这些状态之间转换。而窥探协议是一种实现缓存一致性的方法,通过监听总线上的事务来确保各个处理器缓存的数据一致性。
简单说就是MESI做了实现的定义,窥探(Snooping)”协议是具体实现的案例,保证了缓存数据一致性。
学海无涯苦作舟!!!