Java并发3:互斥锁(上)
原子性
如何理解原子性?
原子性的本质其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。
如何解决原子性?
产生原子性源头问题是线程切换,线程切换依赖CPU中断,那么禁用CPU中断就能禁止线程切换。
单核CPU场景下,禁止CPU进行中断,那么线程不会切换,保证了同一时刻只有一个线程在执行。
多核CPU场景下,禁止CPU进行中断,那么线程不会切换,但同一时刻有多个CPU在执行线程,同一时刻有多个线程在执行。
note:解决原子性问题,是要保证中间状态对外不可见。
同一时刻只有一个线程执行,即互斥,这样无论单核还是多核CPU都可以保证原子性。
锁
解决原子性的方案:锁。
简易锁模型
临界区:一段需要互斥操作的代码,对其在前后进行加锁操作。
改进锁模型
资源和锁之间应该有对应关系,好比不能拿你家的锁锁我家的门。
首先,要把临界区要保护的资源标注出来,如图中临界区里增加了一个元素:受保护的资源 R;
其次,要保护资源 R 需要为它创建一把锁 LR;
最后,针对这把锁 LR,需在进出临界区时添上加锁操作和解锁操作。
另外,在锁 LR 和受保护资源之间,图中特地用一条线做了关联,这个关联关系非常重要。
很多并发 Bug 的出现都是因为把它忽略了,然后就出现了类似锁自家门来保护他家资产的事情,这样的 Bug 非常不好诊断,因为潜意识里认为已经正确加锁了。
synchronized
Java提供synchronized
关键字作为一种锁的实现。
- 用法示例:
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
//实例作为锁定资源
synchronized(obj) {
// 临界区
}
//Class对象作为锁定资源
synchronized(Object.class) {
// 临界区
}
}
}
Java编译器会在synchronized
关键字修饰的方法或代码块上自动执行加锁和解锁动作,这样做的好处是加锁和解锁一定是成对出现。
当synchronized
修饰静态方法时,默认锁定的是当前类的Class对象;
当synchronized
修饰非静态方法时,默认锁定的类是当前的实例对象this;
当synchronized
修饰代码块时,应指定锁定的资源(Class或对象实例)。
- 代码
public class SafeCalc {
long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
public static void main(String[] args) throws InterruptedException {
SafeCalc safeCalc = new SafeCalc();
Thread t1 = new Thread(new Tc(safeCalc));
Thread t2 = new Thread(new Tc(safeCalc));
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(safeCalc.get());
}
static class Tc implements Runnable {
private SafeCalc safeCalc;
public Tc(SafeCalc safeCalc) {
this.safeCalc = safeCalc;
}
@Override
public void run() {
for (int i = 0; i < 100000; i++) {
safeCalc.addOne();
}
}
}
}
如上代码,加了synchronized
后可以完全保证第20行代码打印出来的计算结果一定是200000。
但需要注意的是,由于第4行代码没有加synchronized
关键字,因此addOne()
方法不Happens-Before
于get()
方法。
如果在计算过程中,有其他线程在不断执行get()
方法,此时get()
方法和addOne()
方法是不互斥的,因此不保证每次addOne()
方法执行后对get()
方法的可见性。
但t1
和t2
中执行的addOne()
方法之间的可见性和原子性都保证。
如果要保证get()
方法和addOne()
之间的可见性, 那么get()
方法要加上synchronized
关键字,或者value
变量加volatile
关键字。
锁和受保护资源的关系
受保护资源和锁之间的关联关系为N:1的关系
class SafeCalc {
static long value = 0L;
synchronized long get() {
return value;
}
synchronized static void addOne() {
value += 1;
}
}
如果按上面代码执行,明显可以发现get()
和addOne()
占用的仍然不是一个锁,不存在互斥关系。
临界区的代码是操作受保护资源的路径,要加锁,但不是随便一把锁都能有效。
所以得深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。
思考
下面代码是否正确?
class SafeCalc {
long value = 0L;
long get() {
synchronized (new Object()) {
return value;
}
}
void addOne() {
synchronized (new Object()) {
value += 1;
}
}
}
两个方法用的不是同一个锁,多个线程调用方法时,会一直产生新的锁(新的Object实例),不能保证原子性和可见性。
一个合理的受保护资源与锁之间的关联关系应该是 N:1
多把锁保护同一个资源,就像一个厕所坑位,有N多门可以进去,没有丝毫保护效果,管理员一看,还不如把门都撤了,弄成开放式(编译器代码优化)。
——sbwei
别人的总结
加锁本质就是在锁对象的对象头中写入当前线程id,但是new object每次在内存中都是新对象,所以加锁无效。
——nonohony
经过JVM逃逸分析的优化后,这个sync代码直接会被优化掉,所以在运行时该代码块是无锁的。
——w1sl1y
sync锁的对象monitor指针指向一个ObjectMonitor对象,所有线程加入他的entrylist里面,去cas抢锁,更改state加1拿锁,执行完代码,释放锁state减1,和aqs机制差不多,只是所有线程不阻塞,cas抢锁,没有队列,属于非公平锁。
wait的时候,线程进waitset休眠,等待notify唤醒。——zyl
两把不同的锁,不能保护临界资源。而且这种new出来只在一个地方使用的对象,其它线程不能对它解锁,这个锁会被编译器优化掉。和没有syncronized代码块效果是相同的。
——老杨同志
现实世界里,我们可以用多把锁来保护同一个资源,但在并发领域是不行的。
不能用两把锁锁定同一个资源吗?
如下代码:>public class X { private Object lock1 = new Object(); private Object lock2 = new Object(); private int value = 0; private void addOne() { synchronized (lock1) { synchronized (lock2) { value += 1; } } } private int get() { synchronized (lock1) { synchronized (lock2) { return value; } } } >}
虽然说这样做没有实际意义,但是也不会导致死锁或者其他不好的结果吧?
——石头剪刀布
note:这种属于lock1保护lock2,lock2保护value。