为了保证某段程序的原子性,需要使得同一时刻只有一个线程执行,这就是互斥。
一段需要互斥执行的代码称之为临界区。
线程进入临界区之前,首先试图执行加锁操作lock(),如果成功加锁,则进行临界区执行,此时该线程持有锁;否则就等待其他进程释放锁;持有锁的线程离开临界区后需要释放锁unlock()。
我们都知道锁是用来保护临界资源的,也就是共享变量、共享文件等资源,那么锁的是什么呢?
1、synchronized关键字
Java中的synchronized关键字是java在语言层面提供的互斥原语,是锁的一种实现。synchronized既可以用来修饰方法,也可以只修饰某一段代码块。
比如:
class Clazz{
//synchronized修饰非静态方法
synchronized void fun1(){
//临界区
}
//synchronized修饰静态方法
synchronized static void fun2(){
//临界区
}
Object obj = new Object();
void fun3(){
//synchronized修饰代码块
synchronized(obj){
//临界区
}
}
}
java编译器在synchronized修饰的方法或代码块前后自动加上了加锁lock()和解锁unlock(),这样就可以保证加锁和解锁是成对出现的,防止用户在使用时漏写其中的一个。
那synchronized这把锁到底锁了什么呢?
- 当synchronized修饰static方法(类方法或者静态方法)时,锁的是当前类的Class对象,也就是本例中的Clazz类 ;
- 当synchronized修饰实例方法(非静态方法)时,锁的是当前这个实例对象this;
- 当synchronized修饰代码块时,锁的就是synchronized后面括号中放入的对象,也就是本例中的obj。
2、锁有何限制
对于一段临界资源,只可以有一把锁。锁和受保护资源的关系应该是1 : N。这是合理的,因为如果使用不同的锁去保护某一临界区,那么将会使得多个线程同时持有锁,然后同时进入临界区。
比如:
class Test{
public void fun(){
synchronized(new Object()){
//临界区
}
}
}
上述代码中每次有线程进来,都会使用new创建一个Object对象作为临界区的锁,等于没有上锁。
在一些特殊情况下,多个临界区也可以使用同一把锁。
1)不建议使用同一把锁保护多个资源的情况
比如,对两个相互独立的方法采用同一把锁会导致程序不够精细化,性能太差。
public class Accout{
private Integer balance;
private String password;
private Object obj = new Object();
public void withdraw(Integer amt){
synchronized(obj){
if(balance >= amt){
balance -= amt;
}
}
}
public void updatePassword(String pwd){
synchronized(obj){
password = pwd;
}
}
}
上述代码中,取款withdraw方法和更新密码updatePassword没有关系,但被强制使用了同一把锁,也就是说同一个实例不能同时取款和更新密码,显然效率比较低,这时建议使用不同的锁。
2)必须使用同一把锁保护多个资源的情况
比如转账操作的一致性问题,账户A给账户B转账100元,A减少100元,B增加100元,这两个操作涉及到两个临界资源,分别是A的余额和B的余额。
那假如使用以下方式加锁:
public class Account{
private Integer balance;
synchronized void transfer(Integer amt, Account target){
if(this.balance >= amt){
this.balance -= amt;
target.balance += amt;
}
}
}
账户A和账户B是两个不同的实例,因此这两个实例的transfer方法的锁就是这两个实例本身。于是就出现了两把锁,也就是说A在给B转账的同时,B也可以给C转账。
初始时刻,账户A、B、C余额都是200。线程1和线程2同时读了B的初始余额200,如果线程2先将B的余额减100得到100,再写回内存,然后线程1将B的余额加100得到300,再写回内存,导致最后B的余额是300,而实际上应该是200。
因此,我们需要使用同一把锁来保护多个临界资源。比如使用Account类的Class对象作为transfer方法的锁,然而这样做的性能会很差,因为这样做会导致所有的转账操作都是串行的,而账户A给账户B转账,账户C给账户B转账,实际上是可以同时进行的。所以,将Class对象作为锁的粒度太大了,还需要探讨更细粒度的锁。
比如可以使用两把锁:
public class Account{
private Integer balance;
void transfer(Integer amt, Account target){
synchronized(this){
synchronized(target){
if(this.balance >= amt){
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
这样就可以保证账户A在给账户B转账的时候,账户B不可以转账。但是不会限制账户C给账户D转账。
但这样做也不是万无一失的,因为可能会出现死锁,也就是线程1和线程2同时各自获取了this和target作为其第一把锁,那获取第一把锁时就会陷入永久等待。对于死锁的处理,不是本文的重点,因此不深入介绍。
3)不能把可变对象当做锁
比如在取款方法withdraw中,不可以使用this.balance作为锁。因为当线程A执行该方法,balance -= amt,balance的值发生变化,balance指向了别的对象。那么接下来线程B来执行withdraw方法时就会拿到另一把锁,此时两个线程同时在临界区中执行。
参考资料:
《操作系统精髓与设计原理》
《java并发编程实战》