继上一篇Java高级 - 多线程必知必会之后,我们继续聊一下多线程编程中经常会遇到的同步问题以及对应的解决方式。
首先提一下内存可见性,因为这个涉及到为什么会出现线程的同步问题。
这里引用一幅图来说明:
引用自: http://concurrent.redspider.group/article/02/6.html
由于在Java里面,Java的内存模型规定所有共享变量都存放在主内存中,而每个线程都会把用到的变量从主内存复制到自己的工作内存中。在线程中读写的变量其实都是自己工作内存的变量。
在种情况下,就有可能存在变量不同步的情况。举个例子:
实际上A已经修改为3了,但是线程1还是只能获取到2,这就是所谓的内存可见性问题。出现这个问题的原因就是没有及时更新最新的值到主内存中。
我们可以使用加锁的方式,还有volatile关键字来解决这个问题。
相信这个关键字做Java开发的人经常都会遇到,synchronized是给代码块上锁的一种方式。
通常可以这样做:
public void blockLock() {
Object o = new Object();
synchronized (o) {
// code
}
}
我们通过给对象o上锁的方式达到互斥的效果,同一个对象锁在某一时刻只能被一个线程所获得。
如果某个线程获得了这个对象的内部锁,那么当其他线程在进入synchronized代码块之前,就需要先获得锁,不然的话就会进入挂起阻塞的状态,等待拿到内部锁的线程在释放锁之后再进行竞争。通过这种方式,在退出同步代码块的时候会把本地内存的变量更新到主内存中,在其他线程再获得锁并执行同步代码块的时候,则会重新从主内存中获取,通过这种方式来保证内存可见性。
而为什么说synchronized是一个比较重量级的同步操作呢?因为使用了synchronized之后,其他没有获得锁的线程会从RUNNABLE状态变成WAITING状态,这种从用户态切换到内核态的操作是很耗时的。还记得我们上一篇文章说到过的吗,切换线程的状态通常涉及到上下文切换,我们需要保存和恢复相应的数据。
因此虽然我们可以使用synchronized关键字来实现同步操作,但是如果用更轻量级的替代方式,还是优先考虑别的,例如volatile关键字。
volatile关键字的作用就是确保对一个变量的更新对其他线程马上可见。具体来说就是,使用volatile关键字标识的变量,不会再写入到本地内存中,而是直接更新主内存。
我们通过例子来说明:
public class ThreadLocalDemo {
private int value;
public int get() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
public class ThreadSafe {
private volatile int value;
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
在第一个类里面我们的value没有用volatile修饰的变量,那么如果两个线程同时对value进行修改,就有可能出现内存不可见的问题。但是如果我们用了volatile修饰,那么无论哪个线程修改了value,在主内存中都会是最新的值。
除了保证内存可见性之外,volatile关键字的另外一个重要作用就是禁止指令重排序。编译器和CPU为了提高指令的执行效率可能会进行指令重排序。这会让代码的执行顺序和我们希望的有所出入,导致结果产生偏差。例如:
private SomeClass object = new SomeClass();
这里进行了三步:
但是指令重排序之后有可能是:
这会导致我们访问 object对象的时候,得到的只是一个内存空间的引用而已。这个对象的初始化工作还没进行!我们没办法获得所需要的的真实数据!
因此我们也要对volatile这个关键字多加留意,在一些可能出错的地方,为了保险起见,还是要加上这个关键字。
既然上面我们说到了指令重排序 和内存可见性,那么还有一个不可不提的概念就是:原子性操作。其实原子性操作的定义是:执行一系列操作的时候,要么这些操作全部执行,要么全部不执行,不存在只执行一部分的情况。
举个例子:
int i = i + 1;
这一条简单的赋值语句,其实也经过了好几个步骤:
如果同时有好几个线程执行这个操作,那么有可能在第二步的时候,i的值已经发生了变化,但是没有同步到其他线程中,导致最终结果出错。这个操作就不是一个原子性操作。
我们再举另外一个例子:
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
关键字不具有原子性。
所以我们解决这个问题的方法也很简单,就是上面提到的synchronized和volatile关键字。但是如果用了这种内部锁的机制来保证同步性和原子性,那么并发的效率就会大大降低,因为同一时间只有一个线程能获得内部锁,进而执行对应的操作,其他线程都要等待。
有没有更好的方法来解决这个问题呢?
答案是有的,那就是我们接下来要说的CAS操作
在上面我们提到了,加锁是一个比较耗时的操作,而volatile是轻量级,但是他无法保证写入操作ode原子性,只能保证共享变量的可见性。因此就需要用到CAS,也就是Compare and Swap。 比较更新操作来保证。
一般调用 compareAndSwap() 方法,都需要传入4个参数,分别是:
对象内存位置
对象中的变量的偏移量
变量期望值
变量新的值
然后系统会判断在对象中内存偏移量位置的值是不是期望值,是的话就更新为新的值。这是一个原子性指令。
一般在面试中,提到CAS,面试官都会问一个经典的ABA问题:
假如线程1使用CAS修改初始值为A的变量X,那么线程1首先回去获取当前变量X的值,然后使用CAS操作尝试修改X的值为B,如果CAS操作成功了,那么程序运行一定是正确的吗?
答案是:未必。因为有可能在线程1获取X的值之后,在执行CAS之前,线程2已经使用CAS修改变量的值为B,然后又修改为A,那么这个时候线程1拿到的A的值,其实已经不是一开始的A了。这就是ABA问题。
要避免这个问题,在JDK里面的处理是给每个变量都配备了一个时间戳,判断它到底是不是同一个A。
在Java中,已经提供了实现CAS原理的Unsafe类,我们一起来看看:
boolean compareAndSwapObject(Object o, long offset,Object expected, Object x);
boolean compareAndSwapInt(Object o, long offset,int expected,int x);
boolean compareAndSwapLong(Object o, long offset,long expected,long x);
这些方法都是 public native
的,也就是说都是由JVM来实现的,具体实现和CPU、操作系统有关。
这里我们以AtomicInteger
类的getAndAdd(int delta)
方法为例,来看看Java是如何实现原子操作的。
先看看这个方法的源码:
public final int getAndAdd(int delta) {
return U.getAndAddInt(this, VALUE, delta);
}
这里的U其实就是一个Unsafe
对象:
private static final jdk.internal.misc.Unsafe U = jdk.internal.misc.Unsafe.getUnsafe();
所以其实AtomicInteger
类的getAndAdd(int delta)
方法是调用Unsafe
类的方法来实现的:
@HotSpotIntrinsicCandidate
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!weakCompareAndSetInt(o, offset, v, v + delta));
return v;
}
可以看到,内部其实是通过一个while循环来判断CAS操作是不是成功,如果成功更新了value值,那么就return。否则会不断重试。
乐观锁又称为“无锁”。因为乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待。而一旦多个线程发生冲突,乐观锁通常会使用CAS来保证线程执行的安全性。
由于无锁操作中没有锁的存在,因此不可能出现死锁的情况,也就是说乐观锁天生免疫死锁。
乐观锁多用于“读多写少“的环境,避免频繁加锁影响性能;而悲观锁多用于”写多读少“的环境,避免频繁失败和重试影响性能。
悲观锁就是我们常说的锁。它总是认为每次访问共享资源时会发生冲突,所以必须对每次数据操作加上锁,以保证临界区的程序同一时间只能有一个线程在执行。
我们首先从为什么会出现线程安全问题说起,提到了由于Java的内存模型,导致了内存可见性有可能出现的问题。这其实是一个同步问题,因此我们解决的方式就是通过synchronized关键字保证线程之间的互斥性。然后又因为synchronized关键字是重量级的操作,会导致上下文切换加剧性能开销,因此Java提供了另外一个轻量级的解决方式:volatile关键字,它能保证操作的内存可见性,因为他的底层实现就是保证Java内存模型中的缓存和主内存中的值同步。但是又引申出了另外一个问题,就是虽然它能保证内存可见性,但是它不能保证操作的原子性,于是就提到了CAS操作,这是基于JVM的实现,每个操作系统和CPU有可能不完全一致,它的目的就是把改写操作用一条指令完成,底层实现是通过判断内存偏移量和期望值 是否一致来决定是否要更新新值。最后,我们还提到了悲观锁和乐观锁,这其实就是对应上面我们说到的几种不同的同步方式。
针对不同的场景,我们可以使用不同的策略来保证多线程之间的共享变量同步问题。