为什么要线程同步?
多个线程同时对共享资源进行操作的时候,可能会出现线程干扰或者内存不一致的情况。为了避免这些异常情况,就需要线程同步。
什么是线程干扰?
class Counter {
private int c = 0;
public void increment() {
c++;
}
public void decrement() {
c--;
}
public int value() {
return c;
}
}
如果是多个线程同时对同一个 Counter 对象进行数据操作,可能会因为多个线程的最终执行顺序不同而导致最终结果不同。
代码中对变量 c 的 +1 操作(c++),在JVM执行时,会被解析成多个步骤:
- 获取当前 c 的值
- 对 c 进行 +1 操作
- 将计算后的结果写入内存
c-- 操作与 c++ 操作是一样的拆分步骤,只是第二步的 +1 变成了 -1
多个线程同时进行加减操作,可能会被JVM最终解析成:
- 线程A获取到c
- 线程B获取到c
- 线程A对c进行-1操作
- 线程B对c进行+1操作
- 线程B将结果写回内存,此时c的值是1
- 线程A将结果写回内存,此时c的值是-1
这和预期的结果不相同,预期的结果是:线程 A 对 c+1,c 修改后为 1;线程B对 c-1,c修改后结果为 0
上面所列的只是其中一种情况,也有可能JVM解析后,结果并没有出错。
所以线程干扰是不可预期的。
什么是内存一致性错误?
当两个或者多个线程对同一个共享资源进行操作时,各线程间对共享资源的最新信息并不是即刻知道。
例如:
线程A 对变量 c 进行了 +1 操作;同时线程B在读取变量 c 的值,线程B读取到的值不一定是 1,也可能是 0。
想要避免这个问题,就需要共享资源对所有请求它的线程都是可见的。
synchronized
为了解决上面所说的问题,设计了synchronized 方法和synchronized 代码块。
它可以同时保证不会出现线程操作的原子性和数据的一致性问题。
synchronized 修饰方法
修饰方法时,多个线程同时调用被修饰的方法:
- 只有一个线程可以获得执行这个方法的许可,其他的线程都会因为请求不到执行而被阻塞,直到当前执行的线程结束。
- 所有线程对于共享资源都是可见的,某一个线程的操作对其他线程而言都是可见的。
- 当同一个线程在调用同步代码时,直接或者间接调用了另一个包含同步代码的方法,只要他们持有的是同一个对象锁,就可以重复调用。
修饰静态方法
当 synchronized 用于修饰静态方法时,synchronized 锁住的对象是当前类的Class对象
public static synchronized void staticMethod() {
// 同步静态方法
}
修饰实例方法
当 synchronized 用于修饰实例方法时,synchronized 锁住的对象是当前的实例对象
public synchronized void increment() {
count++;
}
修饰代码块
可以自己指定锁对象,获得括号中的对象锁才能执行其中的代码块
public void method() {
synchronized (lockObject) {
// 同步的代码
}
}
在使用同步代码块时,要注意锁的粒度。只需要锁住多线程操作中需要同步的部分即可。
public class MsLunch {
private long c1 = 0;
private long c2 = 0;
private Object lock1 = new Object();
private Object lock2 = new Object();
public void inc1() {
synchronized(lock1) {
c1++;
}
}
public void inc2() {
synchronized(lock2) {
c2++;
}
}
}
使用实例对象作为锁时,多个线程同时调用添加 synchronized 同步代码块的方法时,只有一个线程可以获得当前实例对象锁,执行同步代码块
使用当前类的Class对象作为锁时,即使实例对象在不同的线程中执行,也只会有一个线程可以执行锁住的代码块(JVM为每一个类管理一个唯一的Class对象)
注意事项
当调用同步方法时,在方法返回时就会释放锁。就算是因为抛出未捕获的异常,也会释放锁。
死锁
两个或多个线程被永久阻塞,彼此等待对方释放锁以继续执行
public class Deadlock {
static class Friend {
private final String name;
public Friend(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
public synchronized void bow(Friend bower) {
System.out.format("%s: %s"
+ " has bowed to me!%n",
this.name, bower.getName());
bower.bowBack(this);
}
public synchronized void bowBack(Friend bower) {
System.out.format("%s: %s"
+ " has bowed back to me!%n",
this.name, bower.getName());
}
}
public static void main(String[] args) {
final Friend alphonse =
new Friend("Alphonse");
final Friend gaston =
new Friend("Gaston");
new Thread(new Runnable() {
public void run() { alphonse.bow(gaston); }
}).start();
new Thread(new Runnable() {
public void run() { gaston.bow(alphonse); }
}).start();
}
}
图解如下:
线程A和线程B都在自己已获得锁的方法中执行代码,去等待对象释放锁以继续执行。使用锁时要避免这种情况的发生
为什么加锁的时候要注意粒度?
一个应用如果所有需要同步操作的地方,都加同一把锁。那么多线程因不同的业务需要同步操作的时候,都要去请求获取该锁。
这些线程,只有一个能获得执行的权限,其他的线程就会一直阻塞,直到上一个线程释放锁后。再去竞争获得锁处理业务。
那么应用要么就是响应特别慢,要么直接卡死。
所以在使用的时候需要注意锁的粒度。不同的业务模块中,自身业务需要加锁进行同步操作的时候,根据具体情况区分哪些代码块需要加锁,哪些代码块需要加同一把锁来避免多线程操作引起的数据问题。