coding……
但行好事 莫问前程

Java编程拾遗『synchronized使用』

从接触Java多线程第一刻起,我们就被告知使用多线程编程一定要注意线程安全问题。而一般的教材中都会首先给出解决线程安全问题的“万金油”——关键字synchronized,它可以保证同一时刻只有一个线程在访问其修饰的代码。但是同时,一般教程中会告诉我们,synchronized是一个重量级锁,以至于给我们一种印象,synchronized效率极低,甚至“谈synchronized”色变。在日常开发中不愿意使用synchronized。

但是随着Java 6对synchronized进行的各种优化(锁粗化、锁清除、自旋锁、偏向锁、轻量级锁),synchronized并不会显得那么重了。本片文章先重点介绍一下synchronized的使用,然后在下篇文章介绍一下synchronized底层原理,及Java6的各种优化。

1. 基本用法

synchronized可以用于修饰类的实例方法、静态方法和代码块,下面分别介绍一下这几种情况。

1.1 synchronized实例方法

首先回顾一个经典的线程安全问题,如下:

public class Counter {
    private int count;

    public Counter() {
        count = 0;
    }

    /**
     * count循环++,每次循环打印自加前的count值
     * 单线程情况下,调用一次incr方法最终打印的结果是9
     */
    public void incr(){
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + ":" + (count++));
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public int getCount() {
        return count;
    }
}

public class CounterRunnable implements Runnable {
    private Counter counter;

    public CounterRunnable(Counter counter) {
        this.counter = counter;
    }

    @Override
    public void run() {
        counter.incr();
    }

    public static void main(String[] args) throws InterruptedException{
        Counter counter = new Counter();

        Thread[] threads = new Thread[5];
        for (int i = 0; i < 5; i++) {
            threads[i] = new Thread(new CounterRunnable(counter), "counter" + i);
            threads[i].start();
        }

        /*主线程等待子线程执行结束*/
        for (int i = 0; i < 5; i++) {
            threads[i].join();
        }

        System.out.println(counter.getCount());
    }
}

在main方法中创建5个子线程,这5个子线程共享一个成员变量counter,自然也就共享Counter内部的成员变量count。其次五个子线程都调用了一次incr方法,对count自加10次,初始值是0,如果没有线程安全问题的话,那么最终5个子线程中,最后一个打印的结果应该是49,getCount方法返回的结果是50。但是执行发现,有时候结果是正确的,有时候getCount方法返回的结果不是50,如下所示:

……
counter3:13
counter4:14
counter4:15
counter0:17
counter2:16
counter3:15
counter1:18
counter2:19
……
counter3:46
counter1:47
counter4:48
49

原因很简单,因为Counter类的incr方法中,count的自增操作不是原子的,有可能两个线程同时读取到相同的count值,然后都进行自增操作,后面执行自增操作的线程就覆盖了前面那个线程的自增结果,比如上面的counter4、counter3线程同时读取到15的count值,导致最终的count的结果不正确(比预期小1)。同时,从incr方法打印的结果看,多线程对incr方法的访问顺序是不确定的。

而解决的方案可以很粗暴,直接使用synchronized关键字修饰incr方法,使同一时刻多个线程中只能有一个线程对incr方法进行访问,其他线程都会阻塞。

public synchronized void incr(){
    for (int i = 0; i < 10; i++) {
        System.out.println(Thread.currentThread().getName() + ":" + (count++));
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

incr方法加上synchronized修饰,这样多个线程对incr方法的访问就是互斥的,不管运行多少次,最后getCount的结果恒为50,并且incr方法的打印结果显示,一个线程调用incr结束之后,其它线程才会执行incr方法。

……
counter1:40
counter1:41
counter1:42
counter1:43
counter1:44
counter1:45
counter1:46
counter1:47
counter1:48
counter1:49
50

synchronized到底做了什么呢?看上去,synchronized使得同时只能有一个线程执行实例方法,但这个理解是不确切的。多个线程是可以同时执行同一个synchronized实例方法的,只要它们访问的对象是不同的,比如说:

public static void main(String[] args) {
    Counter counter1 = new Counter();
    Counter counter2 = new Counter();
    Thread t1 = new Thread(new CounterRunnable(counter1));
    Thread t2 = new Thread(new CounterRunnable(counter2));
    t1.start();
    t2.start();
}

运行结果:

Thread-0:0
Thread-1:0
Thread-1:1
Thread-0:1
Thread-0:2
Thread-1:2
Thread-0:3
Thread-1:3
Thread-0:4
Thread-1:4
Thread-0:5
Thread-1:5
Thread-1:6
Thread-0:6
Thread-0:7
Thread-1:7
Thread-0:8
Thread-1:8
Thread-0:9
Thread-1:9
10
10

可以看到,线程t1和线程t2可以同时执行Counter的incr方法。因为它们访问的是不同的Counter对象,一个是counter1,另一个是counter2(线程1对incr方法的访问需要的锁是counter1,而线程t2对incr方法的访问需要访问的锁是counter2,线程t1与线程t2执行incr方法,不存在锁的竞争)。

所以,synchronized实例方法实际保护的是同一个对象的方法调用,确保持有一个锁对象的多个线程对synchronize修饰的代码的访问,同时只能有一个线程执行。再具体来说,synchronized实例方法保护的是当前实例对象,即this,this对象有一个锁和一个等待队列,锁只能被一个线程持有,其他试图获得同样锁的线程需要等待,执行synchronized实例方法的过程大概如下:

  1. 尝试获得锁,如果能够获得锁,继续下一步,否则加入等待队列,阻塞并等待唤醒
  2. 执行实例方法体代码
  3. 释放锁,如果等待队列上有等待的线程,从中取一个并唤醒,如果有多个等待的线程,唤醒哪一个是不一定的,不保证公平性

synchronized的实际执行过程比这要复杂的多,而且Java虚拟机采用了多种优化方式以提高性能,但从概念上,我们可以这么简单理解,具体的优化放在下篇文章讲解。

这里再强调一下,synchronized保护的是对象而非代码,只要访问的是同一个对象的synchronized方法,即使是不同的代码,也会被同步顺序访问

1.2 synchronized代码块

除了用于修饰实例方法外,synchronized还可以用于包装代码块。比如上面的synchronized修饰的incr方法,就等价于:

public class Counter {
    private int count;

    public Counter() {
        count = 0;
    }

    public void incr(){
    	synchronized(this) {
    		for (int i = 0; i < 10; i++) {
            	System.out.println(Thread.currentThread().getName() + ":" + (count++));
            	try {
                	Thread.sleep(100);
            	} catch (InterruptedException e) {
                	e.printStackTrace();
            	}
        	}
    	}
    }

    public int getCount() {
        return count;
    }
}

synchronized修饰代码块时,括号里面的就是保护的对象(也就是锁),这里的锁是Counter的this对象。对于synchronized修饰实例方法的情况,其实synchronized保护的对象也是类的this对象。当然,synchronized修饰代码块时,括号里面的锁对象可以是任意对象,可以保证多个线程对同一个锁对象保护的代码在访问顺序上是互斥的。上面方法的执行结果跟上面synchronized修饰实例方法的执行结果是一致的。

1.3 synchronized静态方法

synchronized关键字除了可以修饰实例方法和代码块,还可以修饰静态方法,如下所示:

public class Counter {
    private static int count;

    public Counter() {
        count = 0;
    }

    public synchronized static void incr(){
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + ":" + (count++));
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public int getCount() {
        return count;
    }
}
public class CounterRunnable implements Runnable {
    private Counter counter;

    public CounterRunnable(Counter counter) {
        this.counter = counter;
    }

    @Override
    public void run() {
        counter.incr();
    }

    public static void main(String[] args) throws InterruptedException{
        Counter counter1 = new Counter();
        Counter counter2 = new Counter();

        Thread t1 = new Thread(new CounterRunnable(counter1));
        Thread t2 = new Thread(new CounterRunnable(counter2));
        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(counter1.getCount());
        System.out.println(counter2.getCount());
    }
}

运行结果:

Thread-0:0
Thread-0:1
Thread-0:2
Thread-0:3
Thread-0:4
Thread-0:5
Thread-0:6
Thread-0:7
Thread-0:8
Thread-0:9
Thread-1:10
Thread-1:11
Thread-1:12
Thread-1:13
Thread-1:14
Thread-1:15
Thread-1:16
Thread-1:17
Thread-1:18
Thread-1:19
20
20

线程t1和线程t2持有的Counter对象是不同的,但是从结果上看,线程t1和线程t2对incr方法的访问是互斥的,按照之前的分析,如果静态方法保护的对象是this对象,那么线程1和线程2对于incr方法的访问肯定不是互斥的。

对于synchronize修饰的静态方法,其保护的对象不是this对象,而是类对象,就是Counter.class。因为线程t1和线程t2访问incr方法需要的锁都是Counter.class,在JVM规范中,一个类只会有一个类对象,所有的实例对象共享这个类对象。所以线程t1和线程t2对于incr的方法是互斥的。如果使用同步代码块,也可以达到相同的效果,如下:

public void incr(){
    synchronized (Counter.class) {
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + ":" + (count++));
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

2. 理解synchronized

2.1 对象锁和类锁

每个java对象都可以用做一个实现同步的锁,这些锁称为内置锁。线程进入同步代码块或方法的时候会自动获得该锁,在退出同步代码块或方法时会释放该锁,获得内置锁的唯一途径就是进入这个锁的保护的同步代码块或方法。java内置锁是一个互斥锁,这就是意味着最多只有一个线程能够获得该锁,当线程A尝试去获得线程B持有的内置锁时,线程A必须等待或者阻塞,知道线程B释放这个锁,如果B线程不释放这个锁,那么A线程将永远等待下去。对象锁和类锁是synchronize不同使用场景抽象出来的概念,只是一个概念上的东西,并不是真实存在的。

  • 对象锁:用于保护实例方法或者一个实例对象代码块的锁
  • 类锁:用于保护静态方法或者一个类对象代码块的锁

类的对象实例可以有很多个,但是每个类只有一个class对象,所以不同对象实例访问对象锁保护的代码是互不干扰的,但是每个类只有一个类锁,多个对象访问类锁保护的代码是互斥的

2.2 可重入性

synchronized有一个重要的特征,它是可重入的,也就是说,对同一个执行线程,它在获得了锁之后,在调用其他需要同样锁的代码时,可以直接调用,比如说,在一个synchronized实例方法内,可以直接调用其他synchronized实例方法。

可重入是通过记录锁的持有线程和持有数量来实现的,当调用被synchronized保护的代码时,检查对象是否已被锁,如果是,再检查是否被当前线程锁定,如果是,增加持有数量,如果不是被当前线程锁定,才加入等待队列,当释放锁时,减少持有数量,当数量变为0时才释放整个锁。

其实synchronize是重入锁最直观的佐证就是,子类可以重写父类的synchronize方法,如果在子类中调用了super.method()调用父类的方法,并且synchronize不可重入,那必然会产生死锁。但是JVM是允许子类重写父类的synchronize方法的,所以synchronize肯定是可重入的。

public synchronized void childMethod() {
    System.out.println("child method run");
    super.fatherMethod();
}

2.3 死锁

使用synchronized或者其他锁,要注意死锁,所谓死锁就是类似这种现象,比如, 有a, b两个线程,a持有锁A,在等待锁B,而b持有锁B,在等待锁A,a,b陷入了互相等待,最后谁都执行不下去。示例代码如下所示:

public class DeadLockDemo {
    private static Object lockA = new Object();
    private static Object lockB = new Object();

    private static void startThreadA() {
        Thread aThread = new Thread() {

            @Override
            public void run() {
                synchronized (lockA) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                    }
                    synchronized (lockB) {
                    }
                }
            }
        };
        aThread.start();
    }

    private static void startThreadB() {
        Thread bThread = new Thread() {
            @Override
            public void run() {
                synchronized (lockB) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                    }
                    synchronized (lockA) {
                    }
                }
            }
        };
        bThread.start();
    }

    public static void main(String[] args) {
        startThreadA();
        startThreadB();
    }
}

运行后aThread和bThread陷入了相互等待。怎么解决呢?首先,应该尽量避免在持有一个锁的同时去申请另一个锁,如果确实需要多个锁,所有代码都应该按照相同的顺序去申请锁,比如,对于上面的例子,可以约定都先申请lockA,再申请lockB。

不过,在复杂的项目代码中,这种约定可能难以做到。还有一种方法是使用显式锁接口Lock,它支持尝试获取锁(tryLock)和带时间限制的获取锁方法,使用这些方法可以在获取不到锁的时候释放已经持有的锁,然后再次尝试获取锁或干脆放弃,以避免死锁。

3. synchronized与Java内存模型

上篇文章介绍过,synchronize关键字其实就是Java内存模型的一种实现,可以保证原子性、可见性、有序性。

3.1 原子性

原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行。

而线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。

在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。前面中,介绍过,这两个字节码指令,在Java中对应的关键字就是synchronized。

通过monitorenter和monitorexit指令,可以保证被synchronized修饰的代码在同一时间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到。因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。

线程1在执行monitorenter指令的时候,会对Monitor进行加锁,加锁后其他线程无法获得锁,除非线程1主动解锁。即使在执行过程中,由于某种原因,比如CPU时间片用完,线程1放弃了CPU,但是,他并没有进行解锁。而由于synchronized的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码。直到所有代码执行完。这就保证了原子性。

3.2 可见性

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。所以,就可能出现线程1改了某个变量的值,但是线程2不可见的情况。

前面我们介绍过,被synchronized修饰的代码,在开始执行时会加锁,执行完成后会进行解锁。而为了保证可见性,有一条规则是这样的:对一个变量解锁之前,必须先把此变量同步回主存中。这样解锁后,后续线程就可以访问到被修改后的值。

所以,synchronized关键字锁住的对象,其值是具有可见性的。

3.3 有序性

有序性即程序执行的顺序按照代码的先后顺序执行。

除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是可能存在有序性问题。

这里需要注意的是,synchronized是无法禁止指令重排和处理器优化的。也就是说,synchronized无法避免上述提到的问题。

那么,为什么还说synchronized也提供了有序性保证呢?

这就要再把有序性的概念扩展一下了。Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是天然有序的。如果在一个线程中观察另一个线程,所有操作都是无序的。

这其实和as-if-serial语义有关,as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。

这里不对as-if-serial语义详细展开了,简单说就是,as-if-serial语义保证了单线程中,指令重排是有一定的限制的,而只要编译器和处理器都遵守了这个语义,那么就可以认为单线程程序是按照顺序执行的。当然,实际上还是有重排的,只不过我们无须关心这种重排的干扰。

所以呢,由于synchronized修饰的代码,同一时间只能被同一线程访问。那么也就是单线程执行的。所以,可以保证其有序性。

参考链接:

1. 《Java编程的逻辑》

2. 再有人问你synchronized是什么,就把这篇文章发给他

赞(3) 打赏
Zhuoli's Blog » Java编程拾遗『synchronized使用』
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址