Java 线程同步之 synchronized 和 Lock

线程的同步是为了防止多个线程访问一个数据对象时,对数据造成破坏。在多个线程同时访问互斥(可交换)数据时,应该同步以保护数据,确保两个线程不会同时修改更改它。
Java中线程执行步骤:

  1. 清空工作内存;
  2. 从主内存拷贝对象副本到工作内存;
  3. 执行代码(计算或者输出等);
  4. 刷新主内存数据;

Java线程的两个特性,可见性和有序性。多个线程之间是不能直接传递数据交互的,它们之间的交互只能通过共享变量来实现。

1
2
3
4
5
6
7
public class Count {
private int num = 0;
public void count(){
num++;
}
}

假如在多个线程之间共享了Count类的一个对象,这个对象是被创建在主内存(堆内存)中,每个线程都有自己的工作内存(线程栈),工作内存存储了主内存Count对象的一个副本,当线程操作count对象时,首先从主内存复制count对象到工作内存中,然后执行代码count.count(),改变num值,最后用工作内存count刷新主内存count。

  • 可见性: 当一个对象在多个内存中都存在副本时,如果一个内存修改了共享变量,其它线程也应该能够看到被修改后的值。
  • 有序性: 多个线程执行时,CPU对线程的调度是随机的,我们不知道当前程序被执行到哪步就切换到了下一个线程,一个最经典的例子就是银行汇款问题,一个银行账户存款100,这时一个人从该账户取10元,同时另一个人向该账户汇10元,那么余额应该还是100。那么此时可能发生这种情况,A线程负责取款,B线程负责汇款,A从主内存读到100,B从主内存读到100,A执行减10操作,并将数据刷新到主内存,这时主内存数据100-10=90,而B内存执行加10操作,并将数据刷新到主内存,最后主内存数据100+10=110,我们要保证A线程和B线程有序执行,先取款后汇款或者先汇款后取款,此为有序性。

synchronized

同步方法

使用synchronized关键字修饰的方法

Java的每个对象都有一个内置锁,当调用synchronized修饰的方法时,需要先获得内置锁,否则线程就处于阻塞状态。内置锁会保护整个方法,方法执行完成之后线程释放该内置锁。所以,同一时刻,对象的synchronized方法只能有一个处于执行状态。

1
2
public synchronized void modify(){
}

synchronized 关键字也可以修饰静态方法,此时获取的是整个类对象的锁,XXX.class;

1
2
3
4
5
public static synchronized void add() {}
// 等价于
public static void add() {
synchronized(XXX.class){}
}

同步代码块

synchronized关键字修饰的语句块,被该关键字修饰的语句块会自动被加上锁,从而实现同步,此时的锁就是 synchronized 后面所锁定的对象的内置锁;

1
2
synchronized(obj) {
}

同步是一种高开销的操作,因此应该尽量减少同步的内容,通常没有必要同步整个方法,使用synchronized代码块同步关键代码即可。

死锁

两个线程相互等待锁都进入阻塞状态,则发生死锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private static void deadLock() throws Exception {
synchronized (mLock) {
System.out.println("OuterLock");
Future<String> fu = mExecutor.submit(new Callable<String>() {
@Override
public String call() throws Exception {
System.out.println("InnerLock");
// 下面的代码将不会得到执行
synchronized (mLock) {
System.out.println("InnerLockGetted");
}
return "NeverRetunedValue";
}
});
// 下面的代码将不会得到执行
String rs = fu.get();
System.out.println("OuterLockWillUnLock: " + rs);
}
}

锁对象

每个锁对象(JLS中叫monitor)都有两个队列,一个是就绪队列,一个是阻塞队列,就绪队列存储了将要获得锁的线程,阻塞队列存储了被阻塞的线程,当一个线程被唤醒(notify)后,才会进入到就绪队列,等待CPU的调度,反之,当一个线程被wait后,就会进入阻塞队列,等待下一次被唤醒。当第一个线程执行输出方法时,获得同步锁,执行输出方法,恰好此时第二个线程也要执行输出方法,但发现同步锁没有被释放,第二个线程就会进入就绪队列,等待锁被释放。

synchronized 既保证了多线程的并发有序性,又保证了多线程的内存可见性。

Lock

1
2
3
4
5
6
7
8
9
10
11
12
private Lock lock = new ReentrantLock(); // 锁对象
public void add(float v) {
try {
lock.lock(); // 得到锁
this.money += v;
System.out.println(this.getMoney());
} finally {
lock.unlock(); // 释放锁
}
}

注意:用sychronized修饰的方法或者语句块在代码执行完之后锁自动释放,而用Lock需要我们手动释放锁,所以为了保证锁最终被释放(发生异常情况),要把互斥区放在try内,释放锁放在finally内。

ReentrantLock

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class T1 extends Thread {
ReentrantLock l;
public T1(ReentrantLock l) {
this.l = l;
}
@Override
public void run() {
System.out.println(this + " TryLock------------------------");
l.lock();
System.out.println(this + " Locked....: " + System.currentTimeMillis());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
l.unlock();
System.out.println(this + " AfterLock: " + System.currentTimeMillis());
}
}
  • lock()方法用于获取锁对象,如果获取到了锁对象,则线程继续向下执行,反之则线程进入等待阻塞状态。
  • unlock()方法用于释放锁对象,锁被释放后,其它处于阻塞状态的线程将获取到锁对象。
  • 和synchronized不同的是,多个由于调用lock()方法进入阻塞状态的线程,将根据调用lock()方法的时间先后顺序获取到锁,而synchronized则是无序的。
  • lock()、unlock()通常用于比较复杂的业务场景,在线程池(ThreadPoolExecutor)中有使用到。

ReadWriteLock

在对数据进行读写的时候,为了保证数据的一致性和完整性,需要读和写是互斥的,写和写是互斥的,但是读和读是不需要互斥,这样读和读不互斥性能就会更高。

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
private static class Data {
private int age = 22;
private ReadWriteLock lock = new ReentrantReadWriteLock();
private Random random = new Random();
public void setAge(int age, int index) {
try {
lock.writeLock().lock(); // 获取写锁
Thread.sleep(200 * index);
this.age = age;
} catch (Exception e) {
} finally {
print("-- endSetAge..." + Thread.currentThread().getName() + " : " + this.age);
lock.writeLock().unlock(); // 释放写锁
}
}
public int getAge() {
try {
lock.readLock().lock(); // 获取读锁
Thread.sleep(random.nextInt(500));
} catch (Exception e) {
} finally {
lock.readLock().unlock(); // 释放读锁
print(">> endGetAge..." + Thread.currentThread().getName() + " : " + this.age);
return this.age;
}
}
}

模拟线程调用

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
public void main(String[] args) {
final Data data = new Data();
final Random random = new Random();
for (int i = 0; i < 5; i++) {
final int o = i;
new Thread() {
@Override
public void run() {
try {
Thread.sleep(random.nextInt(100));
} catch (InterruptedException e) {
e.printStackTrace();
}
data.setAge(80 + o, (o + 1));
}
}.start();
new Thread() {
@Override
public void run() {
try {
Thread.sleep(random.nextInt(100));
} catch (InterruptedException e) {
e.printStackTrace();
}
data.getAge();
}
}.start();
}
}

锁对象Lock

其它

  1. 只能同步方法,而不能同步变量和类;
  2. 每个对象只有一个锁;当提到同步时,应该清楚在什么上同步?也就是说,在哪个对象上同步?
  3. 不必同步类中所有的方法,类可以同时拥有同步和非同步方法。
  4. 如果两个线程要执行一个类中的synchronized方法,并且两个线程使用相同的实例来调用方法,那么一次只能有一个线程能够执行方法,另一个需要等待,直到锁被释放。也就是说:如果一个线程在对象上获得一个锁,就没有任何其他线程可以进入(该对象的)类中的任何一个同步方法。
  5. 如果线程拥有同步和非同步方法,则非同步方法可以被多个线程自由访问而不受锁的限制。
  6. 线程睡眠时,它所持的任何锁都不会释放。
  7. 线程可以获得多个锁。比如,在一个对象的同步方法里面调用另外一个对象的同步方法,则获取了两个对象的同步锁。
  8. 同步损害并发性,应该尽可能缩小同步范围。同步除了同步整个方法,还可以同步方法中一部分代码块。
  9. 在使用同步代码块时候,应该指定在哪个对象上同步,也就是说要获取哪个对象的锁。
  10. 当两个线程被阻塞,每个线程在等待另一个线程时就发生死锁。

文章开头提到了线程执行步骤,在加锁之后,步骤可以简述为:

  1. 获得同步锁;
  2. 清空工作内存;
  3. 从主内存拷贝对象副本到工作内存;
  4. 执行代码(计算或者输出等);
  5. 刷新主内存数据;
  6. 释放同步锁;

参考

Java线程同步与锁
Java线程同步synchronized