Loading

并发基础知识之synchronized关键字

上一篇文章我总结了一下线程的创建方法以及线程的一些属性,同时还讲了线程的共享以及带来的原子性和内存可见性的问题。这篇文章就讲讲怎么用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。


往期文章

并发基础知识之线程的基本概念
并发基础知识之线程间的协作

posted @ 2018-05-17 19:52  CodeTiger  阅读(37)  评论(0编辑  收藏  举报