JAVA中的排他锁--synchroized

锁的作用,解决原子性问题

例如:32位处理器在并发修改一个long类型的数据为什么是不安全的,原因是long类型是64位的数据在32位处理器中分为高32位和低32位,当2个线程同时修改一个long类型的高32位和低32位时候可能就会引起数据不一致的情况。解决方案是,给这个数据加锁,同时只允许一个线程对他进行修改。java中就是利用synchroized关键字。锁模型如下

avator

synchroized关键字的几种用法

synchroized有3种使用方法分别是锁static方法,锁实例方法,锁方法块,如下:

分别表示:

  1. 修饰静态方法锁类的class对象,当这个静态方法被调用时候,该类所有的加锁的静态方法都会被锁住。
  2. 修饰实例方法,锁当前方法实例化的对象,其他线程对该对象的所有synchronized方法都会被锁住,以及该对象涉及锁住的代码块
  3. 代码块锁对象,该对象修饰的其他的代码块、以及该对象的synchronized都会被锁住。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class X {
// 修饰非静态方法
synchronized void foo() {
// 临界区
}
// 修饰静态方法
synchronized static void bar() {
// 临界区
}
// 修饰代码块
Object obj = new Object();
void baz() {
synchronized(obj) {
// 临界区
}
}
}

锁和被锁资源的关系

被锁资源和锁的关系应该是N:1关系,即一个资源只能对应一个锁。如下就是错误的且实际编程会经常发生

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class SafeCalc {
static long value = 0L;

//锁是SafeCalc的实例
synchronized long get() {
return value;
}

//锁是SafeCalc.class
synchronized static void addOne() {
value += 1;
}

//addOne和get方法对应的不是同一把锁,所以在实际中锁是无效的
}

注意HB原则要切实锁住对象

下图的方法是错误的,因为hb原则没有规定get和addOne不适用于任何一条hb原则,所以当并发开始后,有可能get会出现数据不一致情况。解决方法:

  1. get加synchronized关键字:当并发发生后,解锁hb于加锁,所以后面的方法对前面方法一定可见
  2. 将value设置为volatile:并发发生volitile写hb于读,所以addOne之后get一定可见
1
2
3
4
5
6
7
8
9
class SafeCalc {
volitile long value = 0L;
long get() {
return value;
}
synchronized void addOne() {
value += 1;
}
}

如何一把锁保护多个资源

因为被锁的资源和锁之间关系是N:1的关系,那么如何用一把锁保护多个资源呢

如何保护没有关联关系的锁

如下,对于账户里面的余额和密码,我们采用了俩把锁balLock和pwLock分别保护俩个资源,同时也可用一把锁同时保护俩个资源,即给所有的方法加synchroinzed关键字,但是这样锁的粒度较大性能不如前一种好,因为一个修改余额的操作可能会影响到密码相关的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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;
}
}
}

如何保护有关联关系的资源

例如上面提到的Account对象要提供转账方法,很直观的想法是给transfer方法加锁。如下所示,但是这个方法只是看似正确。因为synchronized方法修饰的调用方法的实例即this对象,并不能锁住target对象。
因为在高并发情况下。如果target也向对方转账,是无法保证taget数据的一致性的。如:a–>b 100元,同时另一个线程b–>c100元.如图:

avator

1
2
3
4
5
6
7
8
9
10
11
class Account {
private int balance;
// 转账
synchronized void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}

解决方案是transfer中的代码块用同一把锁锁住,最好的方法是用Account.class,因为jvm虚拟机所有线程只会加载一个Account对象

注意不能将不可变对象置为锁

因为不可变对象的值一旦发生改变,锁就会失效

锁的优化以及如何规避死锁问题

如上所述,对于转账这个场景给Account.class加锁,虽然并发问题是解决了,但是系统中所有的转账都会变成串行的操作,在现实系统中该方法完全不可用。我们可以采用细粒度的方法对程序进行优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Account {
private int balance;
// 转账
synchronized void transfer(
Account target, int amt){
synchronized(this){
synchronized(target){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}

这个方法是同时锁住转入账户和转出账户,并发问题解决了但是引入了另一个问题,即并发编程最臭名昭著的“死锁”,为什么会发生死锁呢。设想如下场景:
线程T1里a要向b转账,同时线程T2里b也要向a转账,这时候T1拿到了a的锁synchronized(this),同时T2拿到了b的锁synchronized(this),下一步T1和T2因为synchronized(target)同时等待对方释放锁。这时候就进入死了死锁。那么死锁的条件是什么呢?

  1. 互斥:即代码中的target和this俩个线程互斥。
  2. 占有且等待:即T1synchronized(this)占有资源且等待target。
  3. 不释放:T1不会主动释放锁的资源。
  4. 循环等待:上文中说的互相等待。

    那么如何避免死锁呢打破上述的任何一个条件即可。互斥我们是无法打破的我们看下剩下3个条件如何打破。

打破:占有且等待

我们针对“等待”俩个字,让锁一下获得所有的资源。具体方式,我们可以创建一个Allocator方法提供apply申请资源和free释放资源的方法。注意Allocator一定是单例,在锁保证全局唯一,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class Allocator {
private static Allocator instance = new Allocator();
public static Allocator getInstance() {
return instance;
}
private Allocator() {
}

private Set<Account> set = new HashSet<>();
public synchronized boolean apply(Account from, Account to) {
if (set.contains(from) || set.contains(to)) {
return false;
}
set.add(from);
set.add(to);
return true;
}

public synchronized void free(Account from, Account to) {
set.remove(from);
set.remove(to);
}
}

public class Account {
int balance;

public void transfer(Account target, int amt) {
try {
while (!Allocator.getInstance().apply(this, target)) {}
synchronized (this) {
synchronized (target) {
if (balance >= amt) {
balance -= amt;
target.setBalance(target.getBalance() + amt);
}
}
}
} finally {
Allocator.getInstance().free(this, target);
}
}
}

打破:不释放

即主动释放资源,但是java的synchronized关键字因为在出现竞争时候线程会进入阻塞状态,本身是没有这个功能可以用Lock替代

打破:循环等待

这个其实很简单,我们给每个账号上分配一个id,对这个id排序,加锁按照id的顺序加锁,保证每个线程的加锁顺序一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class Account {
long id;
int balance;

public void transfer(Account target, int amt) {
Account first = this;
Account second = target;
//按照id排序
if (first.id < second.id) {
first = target;
second = this;
}
synchronized (first) {
synchronized (second) {
if (balance >= amt) {
balance -= amt;
target.balance += amt;
}
}
}
}

}