java并发编程 -2- 并发问题以及volatile、sync
1 背景
咱们的计算机有3大重要组成:CPU、内存、硬盘。而这三个组成的速度差别是非常明显的,CPU>内存>硬盘,根据木桶原理,硬盘的读写理所应当地成为程序的瓶颈。为了均衡这三者,并且更高效地利用CPU,咱们的操作系统和java编译器做了如下动作:
- CPU增加了缓存,以均衡和内存之间的速度差异,
- 操作系统通过进程和线程对CPU的分时复用,让CPU利用率更高。
- 编译器优化了代码的执行顺序。
这三点的确达到了想要的效果,但是,同时也带来了一些并发问题:
1.1 多核心CPU缓存导致可见性问题
image.png在单核时代,如果不考虑线程切换的前提下,CPU有缓存其实是没有问题的,线程1和线程2都是操作的CPU里缓存的共享变量A,那,所以如果线程1修改了A的值,线程2是马上可以知道的,我们称其为可见性。
image.png
但是进入了多核时代,问题就出来了,线程1和线程2可能分别操作的是不同CPU里缓存的共享变量,当A修改了cpu1里的共享变量的值的时候,线程2是不知道的,直到该值通过同步给了内存,再同步给了CPU2。
public class CountExample {
private long count=0;
public void add10K(){
for(int i=1;i<=10000;i++){
this.count++;
}
}
public long getCount() {
return count;
}
}
@Test
public void cpuCacheTest() throws InterruptedException {
CountExample countExample=new CountExample();
Thread t1=new Thread(()->{
countExample.add10K();
});
Thread t2=new Thread(()->{
countExample.add10K();
});
//启动
t1.start();
t2.start();
//等待两个线程结束
t1.join();
t2.join();
log.info(countExample.getCount()+"");
}
image.png
上面这段代码可以演示可见性问题:两个线程分别对共享变量进行10000次+1操作,结果并不是我们期望得到的20000,而是10000到20000之间的随机数。因为可能发送这种情况:当内存里的值是0的时候,cpu1和cpu2都会把0读到自己缓存里,然后加1,同步给内存,最后内存拿到的就是1,不是2。
那上面的代码在单核上面跑,就没问题了吗??很遗憾,还是有问题。原因这部分代码还有我们马上会说到的原子性问题
1.2 线程切换导致的原子性问题。
什么是原子性操作:不可分割的操作叫做原子性操作。也就是要么都成功,要么都失败,不可能成功了一半。
上面的代码的 count++
这个操作时原子性操作吗? 答案是:否。 它对于java语言来说,是一个操作,但是对于操作系统来说,它是分为了3个步骤:
1、把count的值读取到寄存器,也就是缓存。
2、在寄存器把count的值+1.
3、把寄存器的值同步给内存。
由于我们线程因为分时复用机制而切换,对于2个线程来说,这3步可能发生如下情况:
最后拿到的结果还是1,而不是我们期望的2。
1.3 编译优化导致的有序性问题
编译优化会为我们做什么事情?
编译器会根据自己的判断,把它认为顺序无关的两行代码交换执行顺序。这会带来什么问题呢?举个栗子:
public class SingletomExamle {
private static SingletomExamle singletomExamle;
private SingletomExamle(){
super();
};
public static SingletomExamle getInstance(){
if(singletomExamle==null){
synchronized (SingletomExamle.class){
if(singletomExamle==null){
singletomExamle=new SingletomExamle();
}
}
}
return singletomExamle;
}
}
这是一个典型的双重检查单例模式:1、私有的变量和构造函数。2、获取对象的方法先判断是否为空,如果为空,则通过一个同步块去创建一个对象,为了防止获取到锁的时候,别的线程已经创建成功了对象,同步块里又一次做了非空判断。
看上去完美无缺! 但是,还是有问题。
这里的问题出在了new SingletomExample
这一句代码上。这句代码在我们的想象中,会是这样执行:
1、分配一块内存 M。
2、在M上初始化对象SingletomExample。
3、把singletomExamle指向M的地址。
由于2和3在编译器看来其实是顺序无关的,所以它交换了他们的执行顺序:
1、分配一块内存 M。
2、把singletomExamle指向M的地址。
3、在M上初始化对象SingletomExample。
导致了可能发生下面这种情况:
当线程1还没有初始化对象SingletomExample,但是已经把instants指向M地址,线程2的instants==null
会返回true。那线程2会拿到一个还没有被初始化的instants对象,导致使用的时候出问题。
2 volatile
volatile这个关键字,会带来两个效果:
1、禁用cpu缓存。
2、禁用编译器优化。
所以,volatile是可以解决1.1的缓存问题以及1.3的有序性问题的,比如1.3的代码如果加上volatile就完美了:
public class SingletomExamle {
private volatile static SingletomExamle singletomExamle;
private SingletomExamle(){
super();
};
public static SingletomExamle getInstance(){
if(singletomExamle==null){
synchronized (SingletomExamle.class){
if(singletomExamle==null){
singletomExamle=new SingletomExamle();
}
}
}
return singletomExamle;
}
}
3 synchronized
synchronized 会解决原子性的问题,同时cpu缓存和内存的同步也在这个原子操作之内,所以也解决了可见性问题。比如1.1的代码如果我们家上锁,就一定能得到20000:
public class CountExample {
private long count=0;
public synchronized void add10K(){
for(int i=1;i<=10000;i++){
this.count++;
}
}
public long getCount() {
return count;
}
}
@Test
public void cpuCacheTest() throws InterruptedException {
CountExample countExample=new CountExample();
Thread t1=new Thread(()->{
countExample.add10K();
});
Thread t2=new Thread(()->{
countExample.add10K();
});
//启动
t1.start();
t2.start();
//等待两个线程结束
t1.join();
t2.join();
log.info(countExample.getCount()+"");
}
image.png
3.1 synchronized的基本用法
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}
1、对于静态方法的加锁,相当于:synchronized(X.class)
,锁的是整个类。
2、对于非静态方法的加锁,相当于:synchronized(this)
,锁的是当前对象。
3、对于代码块的加锁,锁的是传入的obj。
那么这里的参数(X.class、this、obj)到底是啥意思的?
它是这个所起作用的范围,比如你锁掉了X.class,那所有调用X.class的静态加锁方法的线程都会一直等待这把锁:
@Slf4j
public class SyncUseExample {
private static String value;
public static synchronized String getValue(){
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
log.error("exception",e);
}
return value;
}
public static synchronized void setValue(String v){
value=v;
}
}
@Test
public void syncUseTest() throws InterruptedException {
Thread t1=new Thread(()->{
log.info("t1 start");
SyncUseExample.getValue();
log.info("t1 end");
});
Thread t2=new Thread(()->{
log.info("t2 start");
SyncUseExample.setValue("1000");
log.info("t2 end");
});
//启动
t1.start();
t2.start();
t1.join();
t2.join();
}
image.png
我们可以看到,线程 t2 "陪"着 t1 sleep了3秒钟,因为他们都是静态方法,所以都是同一把锁(SyncUseExample.class)
我们改成非静态方法,且不同对象再试试:
@Slf4j
public class SyncUseExample {
private String value;
public synchronized String getValue(){
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
log.error("exception",e);
}
return value;
}
public synchronized void setValue(String v){
value=v;
}
}
@Test
public void syncUseTest() throws InterruptedException {
SyncUseExample e1=new SyncUseExample();
SyncUseExample e2=new SyncUseExample();
Thread t1=new Thread(()->{
log.info("t1 start");
e1.getValue();
log.info("t1 end");
});
Thread t2=new Thread(()->{
log.info("t2 start");
e2.setValue("1000");
log.info("t2 end");
});
//启动
t1.start();
t2.start();
t1.join();
t2.join();
}
image.png
因为线程t1 锁的是 e1对象,线程t2使用的是e2对象,所以,t2的执行,并不会等待t1锁的释放。
3.2 锁最合理的范围
弄清楚锁的作用范围,我们再来讨论什么才是最合理的作用范围。锁的范围太小了,不能保证原子性,锁的范围太大了,会把操作都弄成串行操作,影响性能。所以,一定要根据业务实际情况来定锁的范围。以银行转帐举个栗子: A账户转帐给账户B,B账户转账给账户C,每个账户最开始都是200块:
public class SyncExample {
public int money=200;
public void transfer(SyncExample target,int amt){
this.money=this.money-amt;
target.money=target.money+amt;
}
}
@Test
public void syncTest() throws InterruptedException {
while (true){
SyncExample A=new SyncExample();
SyncExample B=new SyncExample();
SyncExample C=new SyncExample();
Thread t1=new Thread(()->{
A.transfer(B,100);
});
Thread t2=new Thread(()->{
B.transfer(C,100);
});
t1.start();
t2.start();
t1.join();
t2.join();
if(A.money!=100||B.money!=200||C.money!=300){
log.info("A:"+A.money);
log.info("B:"+B.money);
log.info("C:"+C.money);
log.info("--------------------");
}
}
}
image.png
这是没加锁的时候,跑一段时间会出现的情况:由于没有锁住B账户,所以线程t1的和t2可能同时拿到B=200的初始状态进行操作,那结果就是其中一个的操作结果被后完成的那个覆盖,那B账户最终结果可能是100 也可能是300,而不是我们期望的200。
那我们把transfer方法加上锁呢:
public class SyncExample {
public int money=200;
public synchronized void transfer(SyncExample target,int amt){
this.money=this.money-amt;
target.money=target.money+amt;
}
}
结果跑一段时间:
image.png
还是有问题,我们不是加了锁了么,为什么呢?
我们前面说过,非静态方法,锁的是当前实例对象,也就是说,t1锁的是账户A!并不是账户B,所以对于账户B来说,跟没加一样!
所以我们的目标是,锁账户B:
public class SyncExample {
public int money=200;
public void transfer(SyncExample target,int amt){
synchronized(SyncExample.class){
this.money=this.money-amt;
target.money=target.money+amt;
}
}
这样就没有并发问题了。 但是,我们锁的范围明显太大了,导致如果账户A向B转账,C向D转账这两个完全可以一起执行的操作都会变成串行操作。
所以,我们只需要 用两把锁掉转账双方即可:
public class SyncExample {
public int money=200;
public void transfer(SyncExample target,int amt){
synchronized(this){
synchronized (target){
this.money=this.money-amt;
target.money=target.money+amt;
}
}
}
}
锁的范围刚刚好!完美! 但是,新的问题来了,这个代码会造成死锁。如果A转给B,同时B转给A,他们都执行了synchronized(this)
这行代码,A锁住了自己,B锁住了自己,同时A等待B锁的释放,B等待A锁的释放。。。。。
关于死锁,我们下一节接着讲。
下一章 java并发编程 - 3 - 死锁问题