一、前言
上一篇博客,我们为了解决各个线程中数据的可见性问题,添加了volatile关键字,确保了线程二对全局参数的变更,能影响到线程一的操作。同时也一起分析了volatile实现的原理和MESI协议以及MESI协议原子性操作的流程。
我们接下来一起加强对volatile关键字的理解。
二、深入了解volatile
并发编程的三大特性:《并发编程三大特性》
1、可见性
2、原子性
3、有序性
大致总结下三大特性的说明:
名称 | 含义 |
---|---|
可见性 | 当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值 |
原子性 | 一个或者多个操作作为一个整体,要么全部执行,要么都不执行 |
有序性 | 程序执行的顺序按照代码的先后顺序执行 |
再上一篇博客《java学习总结——volatile关键字(一)》中,我们具体了解了可见性(一个线程变更了全局变量,其他线程能够识别到变更并失效掉工作内存中的数据,重新获取信息值),但网络上一直流传着这么一个说法:
volatile保证可见性和有序性,但不能保证原子性。
为什么会有不能保证原子性的说法?
我们看一个demo
再这个demo中,我们使用多线程对一个全局变量进行运算操作,判断是否和正常运算的结果有出入。
public class VolatileDemo2 {
public static volatile int num = 0;
public static void increase(){
num ++;
}
public static void main(String[] args) throws InterruptedException {
//1、创建10个线程
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
//十个线程都调用普通方法
threads[i] = new Thread(()->{
for (int j = 0; j < 1000; j++) {
//num ++ 操作执行1000次
increase();
}
});
threads[i].start();
}
//等待所有线程执行完成 才继续执行下面的代码
for (Thread thread : threads) {
thread.join();
}
System.out.println(num);
}
}
多执行几次上述代码,我们看结果打印信息
统计上述运行结果,我们发现:
运行结果值 会小于等于 10000;
可见,volatile不能保证原子性。
为什么不能保证原子性,我们采取图示分析。
三、图示分析
10个线程,每个线程都能拿到数据并运算。
由于此时的线程一store操作经过总线,触发MESI缓存一致性协议,导致线程二中处理的结果失效,促使线程二重新获取最新的num值进行运算操作。
在多个线程操作中,store和write操作存在先后操作,且每个线程执行的速率存在差异,则会导致主内存中num数发生变更(如:num=10---->num=8),则会影响到其他线程获取新的数据时,存在问题。
所以会出现计算结果 ≤ 10000 的操作!
如何解决volatile造成的原子性问题呢?
采取 synchronized 关键字,加锁操作数据操作
四、采取 synchronized 保证 volatile 原子性
手写一个demo
public class VolatileDemo2 {
public static volatile int syncnum = 0;
public static synchronized void syncincrease(){
syncnum ++;
}
public static void main(String[] args) throws InterruptedException {
//1、创建10个线程
Thread[] syncthreads = new Thread[10];
for (int i = 0; i < 10; i++) {
//十个线程都调用同步方法
syncthreads[i] = new Thread(()->{
for (int j = 0; j < 1000; j++) {
syncincrease();
}
});
syncthreads[i].start();
}
//等待所有线程执行完成 才继续执行下面的代码
for (Thread syncthread : syncthreads) {
syncthread.join();
}
System.out.println(syncnum);
}
}
运行结果
为什么加了 synchronized,就能确保获取到的数据保证原子性了?
针对上面这个图,有朋友给我指出了synchronized 关键字修饰方法,应该不是我的这种说法,应该是在如下所示处进行数据操作加锁。
存在争议,所以我额外琢磨了下 synchronized关键字加在方法上修饰和加在类(代码块)上修饰的区别。
五、在方法体上和在方法体里使用synchronized的区别
我们看下面这个demo,分别是线程操作同步方法或同步代码块。
public class SyncDemo2 {
public static volatile int syncnum1 = 0;
public static volatile int syncnum2 = 0;
//同步方法
public static synchronized void syncincrease1(int ints) throws InterruptedException{
System.out.println("Thread Name = "+Thread.currentThread().getName()+" syncincrease ints = "+ints);
System.out.println("-->"+System.currentTimeMillis());
syncnum1 ++;
System.out.println(syncnum1);
//延迟五秒 运行查看线程信息
Thread.sleep(5000);
}
//同步类
public static void syncincrease2 (int ints) throws InterruptedException{
System.out.println("Thread Name = "+Thread.currentThread().getName()+" syncincrease ints = "+ints);
System.out.println("-->"+System.currentTimeMillis());
synchronized(SyncDemo2.class){
syncnum2 ++ ;
System.out.println(syncnum2);
//延迟五秒 运行查看线程信息
Thread.sleep(5000);
}
}
public static void main(String[] args) {
Thread[] threads = new Thread[10];
// for (int i = 0; i < 10; i++) {
// threads[i] = new Thread(()->{
// for (int j = 0; j < 20; j++) {
// try {
// syncincrease1(j);
// } catch (Exception e) {
// // TODO Auto-generated catch block
// e.printStackTrace();
// }
// }
// });
//
// threads[i].start();
// }
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(()->{
for (int j = 0; j < 20; j++) {
try {
syncincrease2(j);
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
});
threads[i].start();
}
}
}
运行上述代码,我们发现,当使用同步代码块时,出现如下所示的现象:
出现了我开辟10个线程,都能拿到处理方法。
如果我屏蔽代码块处理方式,采取同步方法处理呢?
看时间差距,5秒左右获取到一次方法,我设置的5秒间隔。
综上所述,我们得出以下结论:
如果放在方法体上,会阻塞后面的其他线程拿到操作方法。
如果放在代码块上,则不会阻塞其他线程拿到方法,但在数据处理上却会加锁。
上面两个操作获取的结果值是一样的!
参考资料:
《在方法体上和在方法体里使用synchronized的区别》