深入理解并发编程的三个核心概念:原子性、可见性与有序性
目录

第一级:并发编程的三个概念
任务描述
我们在开发应用的时候,经常会关注网站的并发情况。如果网站有很多用户,当这些用户同时访问某个服务时,我们的服务器就会收到大量的并发请求。处理好这些并发请求非常重要。一个合格的程序员必须完成的工作。
了解并发编程的三个概念对于我们更好地开发高并发的Web应用程序会有很大的帮助。
该级别的任务是理解并发编程的三个重要概念并完成右侧的选择题。
相关知识
1.原子性
原子性:即一个操作或多个操作要么完全执行并且在执行过程中不会被任何因素打断,要么根本不执行。
我们看一下下面的代码:
x = 10; //语句1
y = x; //语句2
x++; //语句3
x = x + 1; //语句4
现在请判断这段代码中哪些是原子操作。
你可能认为这4条语句都是原子操作,但实际上只有语句1是原子操作。语句1直接将值10赋给x,因此执行该语句将直接将值10写入内存,因此这是原子的。语句2实际上是两个操作。首先读取x值,然后将其赋值给y。这两个步骤是原子的,但它们组合起来就不是原子操作。接下来的两条语句也是如此。
也就是说,只有简单的读取和赋值(一个数字必须赋给一个变量,变量之间的赋值不是原子操作)才是原子操作。
从上面可以看出,Java内存模型只保证基本的读取和赋值是原子操作。如果要实现大范围的原子性,可以通过synchronized和lock来实现。锁(lock)和同步(synchronized)是稍后出现的。将介绍级别。
Synchronized和Lock可以保证任一时刻只有一个线程执行该代码块,因此保证了原子性。
2. 可见性
可见性是当多个线程访问一个变量并且一个线程改变了该变量的值时,其他线程可以立即知道这一变化。
例如:
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
如果线程 1 由 CPU1 执行,则线程 2 由 CPU2 执行。从上面的分析可以看出,线程1执行语句i=10时,会先将i的初始值加载到CPU1的缓存中,然后赋值给10,然后缓存中i的值CPU1的变为10,但并没有立即写入主存。
此时线程2执行j = i。它首先会从主存中读取i的值,并将其加载到CPU2的缓存中。注意,此时内存中i的值仍然是0,这会使得j的值=0,而不是10。
这是一个可见性问题。线程1修改变量i后,线程2并没有立即看到线程1修改的值。
为了可见性,Java提供了Volatile关键字来保证当一个变量被Volatile修改时,会保证修改后的值会立即重写到主存中。当其他线程想要调用共享变量时,它会去主存。在记忆中再读一遍。
但是,普通共享变量无法保证可见性,因为普通变量会被读入线程自己的内存中。当一个线程修改它时,它可能来不及刷新到主存,其他线程就会从主存中读取它。这个变量。因此,当其他线程读取它时,它可能仍然具有原始值,因此无法保证普通共享变量的可见性。
确保可见性也可以通过 Synchronized 和 lock 来实现。
3、秩序井然
有序性:即程序的执行是按照代码编写的顺序执行的,比如下面的例子:
整数a;布尔标志;i = 10; //语句1 flag = false; //语句2
上面的代码定义了一个整型变量a和一个布尔变量flag。语句1和语句2用于给变量i和flag赋值。看起来语句1在语句2之前,但是程序运行时语句1肯定会在。在语句2之前执行?不一定,因为这里可能会发生指令重排序(Instruction Reorder)。
什么是指令重新排序?
一般来说,为了提高执行效率,处理器会对输入代码进行优化。它不保证代码的执行顺序与代码编写的顺序一致,但会保证程序的输出结果与代码的顺序执行结果一致。
例如,上面的代码中,先执行语句1还是先执行语句2对最终的程序结果没有影响。那么执行过程中就有可能先执行语句2,后执行语句1。
但需要注意的是,虽然处理器会对指令进行重新排序,但它会保证程序的最终结果与代码的顺序执行结果相同。那么它靠什么保证呢?看下面的例子:
int a = 3; //语句1
int b = 5; //语句2
a = a + 3; //语句3
b = b + a + 4; //语句4
上述程序的执行结果可能是:语句2 => 语句1 => 语句3 => 语句4。
是否有可能:语句 2 => 语句 1 => 语句 4 => 语句 3?
不可能,因为处理器在执行语句时会考虑数据之间的依赖关系。上面的代码语句4依赖于语句3的结果,因此处理器会确保语句3先于语句4执行。
如果指令(指令 2)必须使用指令 1 的结果,则处理器将确保指令 1 在指令 2 之前执行。
虽然指令重排序不会影响单线程的最终执行结果,但是在多线程的情况下会不会受到影响呢?让我们看一个例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while(!inited ){
sleep();
}
doSomethingwithconfig(context);
可以发现,语句1和语句2没有数据依赖关系,所以根据指令重排序规则,语句2可能先于语句1执行。语句2执行完后,语句1还没有开始执行,线程2可能已经开始执行。 ,此时inited为true,就会跳出while循环,执行doSomethingwithconfig(context)。此时语句1还没有执行,上下文还没有初始化,会导致程序报错。
从上面的例子可以看出,指令重排序不会影响单线程的执行,但是会影响多线程的执行。
也就是说,要保证多线程程序执行的正确性,就必须保证原子性、可见性和有序性。只要其中之一得不到保证,就可能导致程序运行不正确。
级别2:使用synchronized关键字同步线程
任务描述
这一级别的任务:使右侧代码中的insert方法只能被一个线程同时访问。
相关知识
为了完成这个任务,你需要掌握:
1. 并发编程什么时候会出现安全问题?
2、如何解决线程安全问题;
3.synchronized关键字。
并发编程什么时候会出现安全问题?
安全问题在单线程中不会出现,但在多线程情况下就很有可能出现。例如,多个线程同时访问同一个共享资源,多个线程同时向数据库插入数据。这些时候,如果我们不做任何处理,很可能数据的实际结果与我们的预期结果不符。
现在有两个线程同时获取用户输入的数据,然后将数据插入到同一个表中,要求数据不能重复。
我们在插入数据时必须进行以下操作:
现在有两个线程ThreadA和ThreadB来操作数据库。某一时刻,线程A和B同时读取数据X。这时候他们都去数据库验证X是否存在,得到的结果是不存在。然后线程A和B都将X数据插入到数据库中。此时数据库中出现了两条X数据,仍然出现数据重复。
这是一个线程安全问题。当多个线程同时访问一个资源时,程序运行结果将不是你想要看到的结果。
这里,将该资源称为:关键资源(也称为共享资源)。
当多个线程同时访问关键资源(对象、对象中的属性、文件、数据库等)时,可能会出现线程安全问题。
当多个线程执行一个方法时,方法内部的局部变量不是关键资源,因为方法是在栈上执行的,而Java栈是线程私有的,所以不会有线程安全问题。
如何解决线程安全问题
如何解决线程安全问题?
基本上所有解决线程安全问题的方法都是采用“串行化关键资源访问”,即同一时刻只有一个线程操作关键资源,操作完成后只有其他线程才能操作,也称为同步互斥访问。
在Java中,一般使用synchronized和Lock来实现同步互斥访问。
同步关键字
首先,我们来看看互斥锁。互斥锁:是可以达到互斥访问目的的锁。
如果给变量加了互斥锁,那么同一时间只有一个线程可以访问该变量,即当一个线程访问某一关键资源时,其他线程只能等待。
在Java中,每个对象都有一个锁标记(监视器),也称为监视器。当多个线程访问该对象时,只能访问该对象的锁。
当我们编写代码时,我们可以使用synchronized来修改对象的方法或代码块。当线程访问该对象的synchronized方法或者代码块时,就获得了该对象的锁。此时其他对象无法访问它。您可以等待获取锁的线程完成执行该方法或代码块,然后再执行该对象的方法。
让我们看一个例子来进一步理解synchronized关键字:
public class Example {
public static void main(String[] args) {
final InsertData insertData = new InsertData();
new Thread() {
public void run() {
insertData.insert(Thread.currentThread());
};
}.start();
new Thread() {
public void run() {
insertData.insert(Thread.currentThread());
};
}.start();
}
}
class InsertData {
private ArrayList arrayList = new ArrayList();
public void insert(Thread thread){
for(int i=0;i<5;i++){
System.out.println(thread.getName()+"在插入数据"+i);
arrayList.add(i);
}
}
}
这段代码的执行是随机的(每次结果都不同):
Thread-0 正在插入数据 0 Thread-1 正在插入数据 0 Thread-1 正在插入数据 1 Thread-1 正在插入数据 2 Thread-1 正在插入数据 3 Thread-1 正在插入数据 4 Thread-0 正在插入数据 1 线程-0 正在插入数据 2 Thread-0 正在插入数据 3 Thread-0 正在插入数据 4
现在我们添加synchronized关键字来查看执行结果:
public synchronized void insert(Thread thread){
for(int i=0;i<5;i++){
System.out.println(thread.getName()+"在插入数据"+i);
arrayList.add(i);
}
}
输出:
Thread-0 正在插入数据 0 Thread-0 正在插入数据 1 Thread-0 正在插入数据 2 Thread-0 正在插入数据 3 Thread-0 正在插入数据 4 Thread-1 正在插入数据 0 Thread-1 正在插入数据 1 线程-1 正在插入数据 2 Thread-1 正在插入数据 3 Thread-1 正在插入数据 4
可以发现,线程1会等待线程0插入数据后才执行,说明线程0和线程1是顺序执行的。
从这两个例子我们可以知道synchronized关键字可以实现方法访问的同步和互斥。
使用synchronized关键字时需要注意几个问题:
当一个线程调用一个synchronized方法时,其他的synchronized方法就无法被访问。原因很简单。一个对象只有一把锁;
当一个线程访问对象的同步方法时,其他线程可以访问该对象的非同步方法,因为访问非同步方法不需要获取锁,可以随意访问;
如果一个线程A需要访问对象object1的同步方法fun1,而另一个线程B需要访问对象object2的同步方法fun1,即使object1和object2是同一类型),也不会有线程安全问题,因为他们访问不同的对象。对象,所以不存在互斥问题。
同步代码块
同步代码块对于我们优化多线程代码非常有帮助。首先,我们来看看它是什么样子的:
同步(synObject){}
当这段代码在线程中执行时,线程将获得该对象的synObject锁。此时其他线程无法访问该代码块。 Synchronized的值可以是this,代表当前对象,也可以是对象的一个属性。 ,当使用对象的属性时,代表了该对象属性的锁。
有了synchronized代码块,我们可以将上面添加数据的例子修改为以下两种形式:
class InsertData {
private ArrayList arrayList = new ArrayList();
public void insert(Thread thread){
synchronized (this) {
for(int i=0;i<100;i++){
System.out.println(thread.getName()+"在插入数据"+i);
arrayList.add(i);
}
}
}
}
class InsertData {
private ArrayList arrayList = new ArrayList();
private Object object = new Object();
public void insert(Thread thread){
synchronized (object) {
for(int i=0;i<100;i++){
System.out.println(thread.getName()+"在插入数据"+i);
arrayList.add(i);
}
}
}
}
上面的代码是给同步代码块加锁的两种方式。可以发现添加synchronized代码块比直接在方法中添加synchronized关键字更加灵活。
当我们使用sychronized关键字修饰一个方法时,这个方法只能被一个线程同时访问,但有时可能只需要同步部分代码。这时用sychronized关键字修饰的方法就无法做到了,但是使用sychronized代码块可以实现这个功能。
而如果一个线程执行了一个对象的非静态synchronized方法,而另一个线程需要执行该对象所属类的静态synchronized方法,此时就不会发生互斥,因为访问静态synchronized方法占用空间一个类锁,而访问非静态synchronized方法则占用一个类锁。静态synchronized方法占用对象锁,因此不存在互斥。
我们看一段代码:
public class Test {
public static void main(String[] args) {
final InsertData insertData = new InsertData();
new Thread(){
public void run() {
insertData.insert();
}
}.start();
new Thread(){
public void run() {
insertData.insert1();
}
}.start();
}
}
class InsertData {
public synchronized void insert(){
System.out.println("执行insert");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackT\frace();
}
System.out.println("执行insert完毕");
}
public synchronized static void insert1() {
System.out.println("执行insert1");
System.out.println("执行insert1完毕");
}
}
执行结果:
执行插入 执行插入1 执行插入1 已完成 执行插入已完成
package step2;
public class Task {
public static void main(String[] args) {
final insertData insert = new insertData();
for (int i = 0; i < 3; i++) {
new Thread(new Runnable() {
public void run() {
insert.insert(Thread.currentThread());
}
}).start();
}
}
}
class insertData{
public static int num =0;
/********* Begin *********/
public synchronized void insert(Thread thread) {
for (int i = 0; i <= 5; i++) {
num++;
System.out.println(num);
}
}
/********* End *********/
}
第三级:使用线程锁(Lock)实现线程同步
任务描述
本级任务:使用Lock实现对某段代码的互斥访问。
相关知识
我们在上一关中讨论了synchronized关键字。 Synchronized关键字主要用于同步代码,实现同步互斥访问。也就是说,同一时间只有一个线程可以访问关键资源。这样就解决了线程安全问题。
如果使用synchronized关键字修饰方法或代码块,则当线程获取该方法或代码块的锁时,其他线程无法继续访问该方法或代码块。
如果其他线程可以访问该方法或代码块,则必须等待获取锁的线程释放锁。这里释放锁只有两种情况:
线程执行完代码块后,锁自动释放;
程序报错,jvm允许线程自动释放锁。
可能存在这样的情况:线程获取了对象的锁,在执行过程中由于某种原因(等待IO、调用sleep方法)而被阻塞。此时锁仍在被阻塞线程手中,而其他线程则不在。除了等待,没有其他办法。我们想一下这将如何影响程序的效率。
Synchronized是Java提供的一个关键字,使用起来非常方便,但是在某些情况下,它有很多限制。
因此,需要一种机制来防止等待线程无限等待(比如只等待一定时间或者能够响应中断),这可以通过Lock来完成。
比如多个线程操作同一个文件,同时读写就会冲突,同时写也会冲突,但同时读就不会冲突。如果我们使用synchronized来实现同步,就会出现一个问题:
如果多个线程只进行读操作,当一个线程进行读操作时,其他线程只能等待,无法进行读操作。
因此,需要一种机制来防止多个线程只进行读操作时线程之间发生冲突,而这可以通过Lock来实现。
总的来说,Lock 提供了比synchronized 更多的功能,并且更加可定制。 Lock并不是Java语言内置的,而是一个类。
锁接口
我们再来看看被反复提到的Lock。首先我们看一下它的源码:
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
可以发现Lock是一个接口,其中:lock()、tryLock()、tryLock(long time, TimeUnit unit)和lockInterruptically()方法用于获取锁,unlock()方法用于释放锁。
首先,lock()方法是最常用的方法,它用于获取锁。如果锁已被另一个线程获取,则等待。
前面提到,如果使用Lock,必须主动释放锁,当发生异常时,锁并不会自动释放。因此,一般来说,Lock的使用必须在try{}catch{}块中进行,释放锁的操作必须在finally块中进行,以保证锁必须释放,防止发生这种情况的僵局。
使用锁的示例:
Lock lock = ...;lock.lock();try{//处理任务}catch(Exception ex){}finally{lock.unlock(); //释放锁}
tryLock(),顾名思义,用于尝试获取锁,并且该方法有一个返回值,表示获取是否成功。获取成功则返回true,失败则返回false。从方法中可以发现,如果该方法没有获取锁,则不会继续等待,而是直接返回值。
tryLock()的重载方法tryLock(long time, TimeUnit unit)也有类似的功能,只不过该方法会等待一段时间来获取锁。如果等待时间后仍未获取锁,则返回 false。如果在等待时间内获取到锁,则达到锁则返回true。
所以我们经常这样使用:
Lock lock = ...;if(lock.tryLock()) {try{//处理任务}catch(Exception ex){}finally{lock.unlock(); //释放锁} }else {//如果没有获取锁,直接做其他事情}
正确使用lock()方法
因为Lock是一个接口,所以我们在编程的时候一般都会使用它的实现类。 ReentrantLock是Lock接口的实现类,意思是“可重入锁”。接下来我们通过一个例子来了解lock()方法的正确性。用法。
示例1:
public class Test {
private ArrayList arrayList = new ArrayList();
public static void main(String[] args) {
final Test test = new Test();
new Thread(){
public void run() {
test.insert(Thread.currentThread());
};
}.start();
new Thread(){
public void run() {
test.insert(Thread.currentThread());
};
}.start();
}
public void insert(Thread thread) {
Lock lock = new ReentrantLock(); //注意这个地方
lock.lock();
try {
System.out.println(thread.getName()+"得到了锁");
for(int i=0;i<5;i++) {
arrayList.add(i);
}
} catch (Exception e) {
// TODO: handle exception
}finally {
System.out.println(thread.getName()+"释放了锁");
lock.unlock();
}
}
}
输出:
线程 1 获得锁 线程 0 获得锁 线程 0 释放锁 线程 1 释放锁
结果可能会超出你的预期。不,按理说,一个线程应该获得锁,其他线程无法获得锁。这是为什么呢?这是因为 insert() 方法中的锁变量是局部变量。 THread-0和Thread-1获得不同的锁,这不会导致线程等待。
那么我们如何使用lock()来实现同步呢?相信你已经想到了,只要将Lock定义为全局变量即可。
public class Test {
private ArrayList arrayList = new ArrayList();
private Lock lock = new ReentrantLock(); //注意这个地方
public static void main(String[] args) {
final Test test = new Test();
new Thread(){
public void run() {
test.insert(Thread.currentThread());
};
}.start();
new Thread(){
public void run() {
test.insert(Thread.currentThread());
};
}.start();
}
public void insert(Thread thread) {
lock.lock();
try {
System.out.println(thread.getName()+"得到了锁");
for(int i=0;i<5;i++) {
arrayList.add(i);
}
} catch (Exception e) {
// TODO: handle exception
}finally {
System.out.println(thread.getName()+"释放了锁");
lock.unlock();
}
}
}
结果:
线程 0 获得锁 线程 0 释放锁 线程 1 获得锁 线程 1 释放锁
这是我们预期的结果。
很多时候,为了提高程序的效率,我们不希望线程被阻塞等待锁。这时候我们就可以使用tryLock()来达到目的。
例如,将之前的 insert() 方法修改为 tryLock() 实现:
public void insert(Thread thread) {
if(lock.tryLock()) {
try {
System.out.println(thread.getName()+"得到了锁");
for(int i=0;i<5;i++) {
arrayList.add(i);
}
} catch (Exception e) {
// TODO: handle exception
}finally {
System.out.println(thread.getName()+"释放了锁");
lock.unlock();
}
} else {
System.out.println(thread.getName()+"获取锁失败");
}
}
输出:
Thread-0 获取了锁。 Thread-1 未能获取锁。 Thread-0 释放了锁。
编程要求
请仔细阅读右侧代码,并按照方法中的提示补充Begin-End区域的代码。 ####测试说明
使程序输出如下结果(因为线程的执行顺序是随机的,所以可能需要多次求值):
线程 0 获得锁 1 2 3 4 5 线程 0 释放锁 线程 1 获得锁 6 7 8 9 10 线程 1 释放锁 线程 2 获得锁 11 12 13 14 15 线程 2 释放锁锁 锁
package step3;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Task {
public static void main(String[] args) {
// 创建Insert对象
final Insert insert = new Insert();
// 创建三个线程
Thread t1 = new Thread(new Runnable() {
public void run() {
insert.insert(Thread.currentThread());
}
});
Thread t2 = new Thread(new Runnable() {
public void run() {
insert.insert(Thread.currentThread());
}
});
Thread t3 = new Thread(new Runnable() {
public void run() {
insert.insert(Thread.currentThread());
}
});
// 设置线程优先级
t1.setPriority(Thread.MAX_PRIORITY);
t2.setPriority(Thread.NORM_PRIORITY);
t3.setPriority(Thread.MIN_PRIORITY);
// 启动线程
t1.start();
t2.start();
t3.start();
}
}
class Insert {
public static int num;
// 在这里定义Lock
private ReentrantLock lock = new ReentrantLock();
public void insert(Thread thread) {
/********* Begin *********/
// 加锁
try {
lock.lock();
System.out.println(Thread.currentThread().getName() + "得到了锁");
// 循环累加num
for (int i = 0; i < 5; i++) {
num++;
System.out.println(num);
}
// 解锁
} finally {
lock.unlock();
System.out.println(Thread.currentThread().getName() + "释放了锁");
}
/********* End *********/
}
}
Level 4:使用 volatile 实现变量可见性
任务描述
本级任务:使用 volatile 关键字并同步实现右侧程序输出 10000。
相关知识
在并发编程中,volatile关键字起着非常重要的作用。接下来我们就直接进入主题吧。
什么是 volatile 关键字
volatile 是做什么用的,它的含义和特点是什么?
当共享变量被修改为 volatile 时,它就具有了“可见性”,即当该变量被一个线程修改时,这一变化会立即被其他线程知晓。
当共享变量被修改为易失性时,“指令重新排序”被禁用。
我们先看一个例子:
//线程1boolean stop = false;while(!stop){doSomething();}//线程2stop = true;
因为线程不直接提供停止方法,所以当我们想要中断线程时,我们经常使用上面的代码。
但是,这段代码有一个问题。当线程2执行时,这段代码能保证线程1会被中断吗?大多数情况下这是可能的,但也可能存在线程 1 无法被中断的情况。
为什么线程可能无法被中断?每个线程在运行时都有自己的工作内存,所以当线程1运行时,它会复制stop变量的值,放到自己的工作内存中。
然后,当线程2更改了stop变量的值,但还没来得及将其写入主内存时,线程2就转而去做其他事情。那么线程1并不知道线程2对stop变量的改变,所以会继续循环。下去。
如何避免这种情况呢?
很简单,只需在stop变量中添加 volatile 关键字即可。
volatile 关键字会有什么作用呢?
使用 volatile 关键字将强制变量的修改值立即写入主存;
使用 volatile 关键字,当线程 2 修改变量 stop 时,会强制所有使用 stop 变量的线程对应的缓存中的 stop 缓存行失效。
由于线程1的stop缓存行无效,线程1会在运行时读取主存中stop变量的值。
所以最终线程1读取到的就是stop的最新值。
volatile能保证原子性吗?
前面我们了解了线程的三大特性:原子性、可见性、有序性。
在前面的例子中,我们知道 volatile 可以保证共享变量的可见性,但是 volatile 能保证原子性吗?
我们来看一下:
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
我们想想这个程序的输出,然后复制到本地运行一下看看效果。
也许我们想要的结果应该是:10000,但是最终的运行结果往往达不到10000。也许我们有疑问。不是,上面是对变量inc的自增操作。既然 volatile 保证了可见性,那么每次在一个线程中递增 inc 后,修改后的值就可以在其他线程中看到。因此,10个线程分别执行了1000次操作,所以最终的inc值应该是1000*10=10000。
前面我们提到了 volatile 可以保证可见性,但是上面程序的错误在于 volatile 无法保证程序的原子性。
我们知道变量的增量不是原子的。它由两个步骤组成:
1、读取变量的值;
2. 变量值加1,写入工作内存。
我们想象这样一种情况:线程1在操作增量变量时可能会遇到这种情况。读取 inc 变量的值。此时inc的值为10,线程1还没有执行自增操作。被阻塞,然后线程2对inc变量进行操作。注意此时inc的值仍然是10。线程2对inc执行自增操作。此时inc的值为11,这个变化被写入到主存中。好了,现在线程1已经恢复了,它不会从主存中读取inc的值,因为inc已经在它的缓存中了。 ,所以继续前面的操作。注意,此时线程1的缓存中inc的值为10,线程1将inc的值加1。 inc等于11,然后写入主存。
我们发现两个线程都对inc进行了一轮操作,但是inc的值只增加了1。
或许我们还有疑问,不对,不是保证当一个变量修改了一个 volatile 变量时,缓存行就会失效吗?然后其他线程就会读取新值,是的,这是正确的。这就是上面的happens-before规则中的volatile变量规则,但是需要注意的是,线程1读取变量并被阻塞后,inc值并没有被修改。那么虽然 volatile 可以保证线程 2 从内存中读取变量 inc 的值,但是线程 1 并没有对其进行修改,所以线程 2 根本不会看到修改后的值。
明白了这一点,我们就可以明白这个问题的根源在于增量操作不是原子的。
解决这个问题很简单,只要让自增操作原子化即可。
如何保证原子性?上面代码的结果怎么会是10000呢?
只需使用我们在前两个级别中学到的知识即可。具体代码你自己想一下。毕竟,你自己的想法就是你自己的。
编程要求
请仔细阅读右侧代码,并按照方法中的提示补充Begin-End区域的代码。 ####测试说明
预期产量:10000。
提示:有两种方法可以实现原子性,因此有多种方法可以完成这一级别。
package step4;
public class Task {
// 声明一个名为inc的volatile变量,保证线程可见性
public volatile int inc = 0;
/********** Begin **********/
// 声明一个名为increase的同步方法,用于对inc变量进行加1操作
public void increase() {
inc++;
}
/********** End **********/
// 声明一个名为main的静态方法,用于测试
public static void main(String[] args) {
final Task test = new Task();
for (int i = 0; i < 10; i++) { // 创建10个线程并启动
new Thread() {
// 重写run方法,在每个线程中对test对象的inc变量进行1000次加1操作
public void run() {
for (int j = 0; j < 1000; j++)
test.increase();
};
}.start();
}
// 等待所有线程执行完毕
while (Thread.activeCount() > 1) // 保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}