上一篇文章我总结了一下线程的创建方法以及线程的一些属性,同时还讲了线程的共享以及带来的原子性和内存可见性的问题。这篇文章就讲讲怎么用synchronized关键字解决那两个问题。
1.synchronized的用法和基本原理
synchronized可以修饰实例方法,静态方法和代码块。
上篇我们讲了一个counter计数器的问题,由于counter++不是一个原子操作,所以在多线程中,输出的结果往往不是我们所预期的,现在我们看看怎么分别用着三种方式解决这个问题。
(1)修饰实例方法
public class Counter {
private int counter = 0;
public synchronized void incr() {
counter++;
}
public synchronized int getCounter() {
return counter;
}
}
Counter类是一个简单的计数器类,里面有两个方法,一个让计数加1,一个返回计数的值,都加了synchronized 修饰,这样方法内的代码就是原子操作,当多个线程更新同一个Counter对象的时候,也不会有问题。
public class CounterThread extends Thread {
private Counter counter;
public CounterThread(Counter counter) {
this.counter = counter;
}
@Override
public void run() {
for(int i = 0; i < 1000; i++) {
counter.incr();
}
}
public static void main(String[] args) throws InterruptedException {
int num = 1000;
Counter counter = new Counter();
Thread[] threads = new Thread[num];
for(int i = 0; i < num; i++) {
threads[i] = new CounterThread(counter);
threads[i].start();
}
for(int i = 0; i < num; i++) {
threads[i].join();
}
System.out.println(counter.getCounter());
}
}
不论运行多少次,都是输出1000*1000。
那么这里的synchronized到底起了什么作用呢?表面上看,是让同时只能有一个线程执行实例方法,但其实这是有条件的,那就是同一个对象。是的,如果多个线程访问同一个对象的实例方法,那么synchronized就会让线程按顺序来执行,如果是不同对象,那么多个线程时可以同时访问同一个synchronized方法的,只要它们访问的对象是不同的即可。
比如
Counter c1 = new Counter();
Counter c2 = new Counter();
Thread t1 = new CounterThread(c1);
Thread t2 = new CounterThread(c2);
t1.start();
t2.start();
这里,t1和t2两个线程时可以同时执行Counter的incr方法的,因为它们访问的是不同的Counter对象。
相反,如果访问的是同一个对象的synchronized方法,那么即使是不同的synchronized方法,也需要等待的。比如Counter类中的getCounter和incr,对同一个Counter对象,一个线程执行getCounter方法,一个线程执行incr方法,虽然是不同的方法,但它们还是不能同时执行,会被synchronized同步顺序执行。
所以,synchronized实际保护的是同一个对象的方法调用,确保同时只要一个线程执行。再具体来说,synchronized保护的是当前的实例对象,即this,this对象有一个锁和一个等待队列,锁只能被一个线程拥有,其他线程要获得同样的锁需要等待。执行synchronized修饰的实例方法的大致过程如下:
1.尝试获得锁,如果能获得,执行下一步,否则加入等待队列,阻塞并等待唤醒,线程状态变成BLOCKED。
2.执行实例方法内的代码。
3.释放锁,如果等待队列里有等待的线程,则取一个唤醒,如果有多个,则随机,不保证公平性。
synchronized实际的执行过程比这复杂得多,但我们可以这样简单的理解。
此外还要说明的是,synchronized方法不能防止非synchronized方法被同时执行,比如给Counter类加一个非synchronized方法,则该方法可以和incr方法一起执行,这通常会出现意想不到的结果,所以,对于一个变量来说,一般给该访问该变量的所有方法加上synchronized。
(2)修饰静态方法
public class StaticCounter {
private static int counter = 0;
public static synchronized void incr() {
counter++;
}
public static synchronized int getCounter() {
return counter;
}
}
前面我们说,synchronized修饰实例方法,保护的是当前实例对象this,那么修饰静态方法,保护的是那个对象呢?是类对象。对上面的例子也就是StaticCounter.class,每个对象都有一个锁和一个等待队列,类对象也不例外。
因为synchronized静态方法和synchronized实例方法保护的是不同的对象,所以不同的两个线程,可以一个执行synchronized静态方法,一个执行synchronized实例方法。
(3)修饰代码块
public class Counter {
private int counter = 0;
public void incr() {
synchronized(this) {
counter++;
}
}
public int getCounter() {
synchronized(this) {
return counter;
}
}
}
synchronized括号里面就是保护的对象。对于实例方法,就是this。对于前面的StaticCounter类,等价代码如下
public class StaticCounter {
private static int counter = 0;
public static void incr() {
synchronized(StaticCounter .class) {
counter++;
}
}
public static int getCounter() {
synchronized(StaticCounter .class) {
return counter;
}
}
}
synchronized同步的对象可以是任意对象,任意对象都有一个锁和一个等待队列,或者说,任何对象都可以成为锁对象。
比如Counter的等价代码还可以如下
public class Counter {
private int counter = 0;
private Object lock = new Object();
public void incr() {
synchronized(lock) {
counter++;
}
}
public int getCounter() {
synchronized(lock) {
return counter;
}
}
}
2.进一步了解synchronized
介绍了synchronized的基本用法和原理之后,现在从以下三个方面进一步介绍
- 可重入性
- 内存可见性
- 死锁
(1)可重入性
可重入性是指如果一个线程获得一个锁之后,在调用其他需要同样锁的代码时,可以直接调用。比如在一个synchronized实例方法内,可以直接调用其他synchronized实例方法。
可重入是通过记录锁的持有线程和持有数量来实现的。当调用synchronized保护的代码时,检查对象是否被锁,如果是,再检查是否是被当前线程持有,如果是,增加持有数量,如果不是,则线程加入等待队列,当释放锁时,减少持有数量,当持有数量变为0的时候,才释放整个锁。
(2)内存可见性
synchronized除了可以保证原子性之外,还能保证内存可见性。在释放锁的时候,所有写入都会写入内存,而获得锁后,都会从内存中读最新数据。
但如果只是为了保证内存可见性,使用synchronized成本有点高,我们可以使用volatile关键字修饰变量,比如上篇文章中的内存可见性问题,代码可以该成如下,就可以解决内存可见性问题。
public class VisibilityDemo {
private static volatile boolean shutdown = false;
static class HelloThread extends Thread {
@Override
public void run() {
while(!shutdown) {
System.out.println("1");
}
System.out.println("exit hello");
}
}
public static void main(String[] args) throws InterruptedException {
new HelloThread().start();
Thread.sleep(1000);
shutdown = true;
System.out.println("exit main");
}
}
可以看到使用volatile修饰了shutdown变量。加入volatile后,java会在操作对应变量时插入特殊的指令,保证读写到内存最新值,而非缓存的值。
(3)死锁
使用synchronized或者其他锁,可以回产生死锁,比如,有a,b两线程,a线程持有锁A,等待锁B,b线程持有锁B,等待锁A,这样a,b就互相等待,永远不会执行。
public class DeadLockDemo {
private static Object lock1 = new Object();
private static Object lock2 = new Object();
private static void startThreadA() {
Thread thread1 = new Thread() {
@Override
public void run() {
synchronized (lock1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock2) {
}
}
}
};
thread1.start();
}
private static void startThreadB() {
Thread thread2 = new Thread() {
@Override
public void run() {
synchronized (lock2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (lock1) {
}
}
}
};
thread2.start();
}
public static void main(String[] args) {
startThreadA();
startThreadB();
}
}
应该尽量避免在持有一个锁的同时去申请另外一个锁,如果确实需要多个锁,所有代码应该按照相同的顺序去申请锁。对于上面的例子,可以约定都先申请lock1,再申请lock2。
网友评论