多线程编程核心技术(四)互斥锁:解决原子性问题
上一个文章已经介绍出了线程切换导致无法实现原子性。那么一个问题:单核CPU是否有原子性问题,是依然有安全性问题的。
public class OneCpuCoreTest implements Runnable { private static int count; @Override public void run() { int idx = 0; while (idx++ < 100000) { count += 1; } } public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(new OneCpuCoreTest()); Thread thread2 = new Thread(new OneCpuCoreTest()); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(count); } }
可以上自己的学生服务器上去试一试。
“同一时刻只有一个线程执行”这个条件非常重要,我们称之为互斥。如果我们能够保证对共享变量的修改是互斥的,那么,无论是单核 CPU 还是多核 CPU,就都能保证原子性了。
重点就是保持“同一时刻只有一个线程执行”。
进行操作之前先进行加锁,就保证了只有一个线程可以对这一部分代码进行处理。相对而且权限比较低的就是synchronized
当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X;当修饰非静态方法的时候,锁定的是当前实例对象 this。像最上面的代码只需要修改成这样就可以
public class OneCpuCoreTest implements Runnable { private static Integer count=new Integer(0); @Override public void run() { synchronized (count){ int idx = 0; while (idx++ < 100000) { count += 1; } } } public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(new OneCpuCoreTest()); Thread thread2 = new Thread(new OneCpuCoreTest()); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(count); } }
但是如果是在方法(run方法)上加synchronized是没什么作用的,原因就是加在方法上的锁是锁当前的this对象。而在下面的主方法中是new了两个对象,虽然所以依然都可以进去。如果要在方法上修改的话,需求这样做。
public class OneCpuCoreTest implements Runnable { private static Integer count=new Integer(0); @Override public synchronized void run() { int idx = 0; while (idx++ < 100000) { count += 1; } } public static void main(String[] args) throws InterruptedException { OneCpuCoreTest test = new OneCpuCoreTest(); Thread thread1 = new Thread(test); Thread thread2 = new Thread(test); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(count); } }
为什么synchronized修饰了方法,最后另外的线程会知道最新的值呢?可以从happend-before方法看出,解锁总是在加锁之前,所以另外一个线程要知道已经解锁了,就会根据传递性知道最新值。synchronized也被称为管程
互斥锁,在并发领域的知名度极高,只要有了并发问题,大家首先容易想到的就是加锁,因为大家都知道,加锁能够保证执行临界区代码的互斥性。这样理解虽然正确,但是却不能够指导你真正用好互斥锁。临界区的代码是操作受保护资源的路径,类似于球场的入口,入口一定要检票,也就是要加锁,但不是随便一把锁都能有效。所以必须深入分析锁定的对象和受保护资源的关系,综合考虑受保护资源的访问路径,多方面考量才能用好互斥锁。synchronized 是 Java 在语言层面提供的互斥原语,其实 Java 里面还有很多其他类型的锁,但作为互斥锁,原理都是相通的:锁,一定有一个要锁定的对象,至于这个锁定的对象要保护的资源以及在哪里加锁 / 解锁,就属于设计层面的事情了。
然后就是加锁本质就是在锁对象的对象头中写入当前线程id这个是原理一定要记得。new对象的要注意,下面的代码就是无效的。另外JVM逃逸分析的优化后,这个sync代码直接会被优化掉
class SafeCalc { long value = 0L; long get() { synchronized (new Object()) { return value; } } void addOne() { synchronized (new Object()) { value += 1; } } }
如果说N个资源只对应了一把锁,就会有一定的问题,当然不是安全问题,是线程性能问题。例如下面这个代码,就会导致同一时间只可以进行一个方法,存钱了就不能查。
class Account { // 锁:保护账户余额 private final Object balLock = new Object(); // 账户余额 private Integer balance; // 锁:保护账户密码 private final Object pwLock = new Object(); // 账户密码 private String password; // 取款 void withdraw(Integer amt) { synchronized(balLock) { if (this.balance > amt){ this.balance -= amt; } } } // 查看余额 Integer getBalance() { synchronized(balLock) { return balance; } } // 更改密码 void updatePassword(String pw){ synchronized(pwLock) { this.password = pw; } } // 查看密码 String getPassword() { synchronized(pwLock) { return password; } } }
这样的做,可以保证安全,但是会让别的线程去自旋。
如果用不同的锁对受保护资源进行精细化管理,就能够提升性能。这种锁叫细粒度锁。这种锁可以通过加锁位置的不同来实现不同的颗粒度,给需要加锁的地方,进行最小颗粒度的加锁。这种叫减少锁的时间
又例如ConcurrentHashMap中的切割思想,每一个行加上小锁
看一段代码,这个方法是否是安全的。看起来方法上加了锁是安全的,但是有个问题就是锁加的位置就是方法,也就是锁住当前的实例。如果说传入的Account是无法保护住的。
class Account { private int balance; // 转账 synchronized void transfer( Account target, int amt){ if (this.balance > amt) { this.balance -= amt; target.balance += amt; } } }
如果要给这个两个对象都同时上锁,方法有很多,一种是锁Account.class对象,也可以在内部构造的时候传入一个lock对象。