Skip to content

并发编程之 Synchronized

Synchronized 关键字

  • synchronized 关键字锁定的是对象不是代码块, demo 中锁的是 object 对象的实例
  • 锁定的对象有两种:1.类的实例 2.类对象(类锁)
  • 加 synchronized 关键字之后不一定能实现线程安全,具体还要看锁定的对象是否唯一。

锁定某个对象

java
public class Demo1 {
    private int count = 10;
    private Object object = new Object();
    public void test(){
        synchronized (object){
            count--;
            log.debug(Thread.currentThread().getName() + " count = " + count);
        }
    }
}

锁定当前类的实例

java
public class Demo2 {
    private int count = 10;
    public void test() {
        //synchronized(this) 锁定的是当前类的实例, 这里锁定的是 Demo2 类的实例
        synchronized (this) {
            count--;
            log.debug(Thread.currentThread().getName() + " count = " + count);
        }
    }

    //直接加在方法声明上,相当于是上面的 synchronized(this)
    public synchronized void test2() {
        count--;
        log.debug(Thread.currentThread().getName() + " count = " + count);
    }
}

锁定当前类的 class 对象(注意区别 synchronized(this),两者不是一个概念)

java
public class Demo4 {
    private static int count = 10;
    //synchronize关键字修饰静态方法锁定的是类的对象
    public synchronized static void test(){
        count--;
        log.debug(Thread.currentThread().getName() + " count = " + count);
    }

    public static void test2(){
        //这里不能替换成this,不是一个概念,相当于上面加在静态方法上的 synchronized 一样
        synchronized (Demo4.class){
            count--;
        }
    }
}

锁对象的改变

  • 锁定某对象 o,如果 o 的属性发生改变,不影响锁的使用
  • 但是如果 o 变成另外一个对象,则锁定的对象发生改变
  • 应该避免将锁定对象的引用变成另外一个对象
java
public class Demo1 {
    Object o = new Object();
    public void test(){
        synchronized (o) {
            //t1 在这里无限执行
            while (true) {
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                log.debug(Thread.currentThread().getName());
            }
        }
    }

    public static void main(String[] args) {
        Demo1 demo = new Demo1();
        new Thread(demo::test, "t1").start();

        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        Thread t2 = new Thread(demo::test, "t2");

        // 这里发生了锁对象的改变,锁变成了一个新的对象
        demo.o = new Object();
        //t2能否执行? 答案是可以执行,因为发生了锁对象的改变。
        // 假如没有上面一行的代码,那么按照逻辑,t1 会一直持有锁,并且无限执行下去,因此代码一直阻塞在 t1 处,t2 要想执行,必须等待 t1 释放锁才行,因此 t2 不会被执行。
        // 然而,当锁对象发生了改变,t1 依然持有原来的锁对象,但此时 t2 已经不需要原来的 t1 持有的锁对象了,而变成了一个新的锁对象,此时,t1 和 t2 分别持有各自不同的锁对象,因而,t1 不会再阻塞 t2,换言之,t2 无需跟 t1 竞争同一把锁,t2 已经可以拿到自己独有的锁对象了,因而,t2 会被执行。
        t2.start();
    }
}

不要以字符串常量作为锁定的对象

下面的代码会先输出 “t1 start...”,然后休眠 5 秒,继续输出 “t1 end...”,再然后输出 “t2 start...”

定义了两个 String 对象作为锁,按道理说,t1 和 t2 应该互不影响才是,可结果却是 t1 会 阻塞 t2 线程,为什么呢?

因为定义的两个 String 对象的内容是一样的,由于 Java 中字符串常量池的存在,实际上,这两个字符串对象指向的是同一个字符串对象的引用,因此,实际上,t1 和 t2 的锁对象是同一个对象,故而他们需要竞争同一把锁,进而 t2 必须等待 t1 释放锁,t2 才能继续执行。

因此,在开发过程中要避免使用字符串常量作为锁定的对象。因为很可能你写的代码跟同事写的虽然是不同业务的代码,但都使用了字符串锁,加的锁都是同一个字符串内容,由于字符串常量池的存在,实际上锁的是同一个对象,进而引起不必要的编程问题。

java
public class Demo2 {
    String s1 = "hello";
    String s2 = "hello";
    public void test1(){
        synchronized (s1) {
           log.debug("t1 start...");
            try {
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            log.debug("t1 end...");
        }
    }

    public void test2(){
        synchronized (s2) {
            log.debug("t2 start...");
        }
    }

    public static void main(String[] args) {
        Demo2 demo = new Demo2();
        new Thread(demo :: test1,"t1").start();
        new Thread(demo :: test2,"t2").start();
    }
}

同步代码快中的语句越少越好

  • 比较 test1 和 test2
  • 业务逻辑中只有 count++ 这句需要 synchronized,这时不应该给整个方法上锁
  • 采用细粒度的锁,可以使线程争用时间变短,从而提高效率
java
public class Demo3 {
    int count = 0;
    public synchronized void test1(){
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        count ++;

        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    /**
     * 局部加锁
     */
    public void test2(){
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        synchronized (this) {
            count ++;
        }

        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

同步方法和非同步方法是否可以同时调用?

答案是可以。

简单想一下,如果两个线程持有的是不同的两个锁对象,那么这两个线程不需要竞争锁,没有影响,他们都可以同时调用,那么非同步方法就更不影响了。

脏读问题

如果问你,有一个 List 集合容器,一般情况下,在插入数据时,会考虑加锁来避免多线程安全问题,那么在读取数据时,需不需要加锁呢?

答案是:实际业务当中应该看是否允许脏读,不允许的情况下对读方法也要加锁。

重入锁——方法调用

一个同步方法调用另外一个同步方法,能否得到锁? 可以

synchronized 默认支持重入。下面这个示例就是一种重入锁,test2 方法是可以拿到锁的。

java
public class Demo {
    synchronized void test1() {
        log.debug("test1 start.........");
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        test2();
    }

    /**
     * 为什么test2还需要加 synchronized?他本身就包含在 test1 中,而 test1 已经加了 synchronized。
     * 答案是:为了避免外面的类直接调用 test2 方法而可能产生的多线程安全问题。
     */
    synchronized void test2(){
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.debug("test2 start.......");
    }

    public static void main(String[] args) {
        Demo demo= new Demo();
        demo.test1();
    }
}

重入锁——类继承

如下所示,类的继承,虽然表面上看起来锁的都是父子类各自的 this 对象,而真正的 this 对象指向的都是 Demo2 new 出来的实例对象,因此,这两个方法使用的是同一把锁,又发生了方法调用,因此产生了锁的重入。

java
public class Demo {
    synchronized void test(){
        log.debug("demo test start........");
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.debug("demo test end........");
    }

    public static void main(String[] args) {
            new Demo2().test();
    }
}

class Demo2 extends Demo {
    @Override
    synchronized void test(){
        log.debug("demo2 test start........");
        super.test();
        log.debug("demo2 test end........");
    }
}

synchronized 和异常的关系

当线程发生异常时,如果异常被捕获了,那么视为程序运行没有问题,不会因此而释放锁。

但如果异常没有被捕获,异常抛出,这时候会释放锁。这一点从 java 运行时汇编指令也可以看出,当某条执行执行出错时,计算机执行指令会直接执行释放锁的指令。

volatile 关键字,使一个变量在多个线程间可见

  • main, t1 线程都用到一个变量,java 默认是 T1 线程中保留一份副本,这样如果 main 线程修改了该变量,t1 线程未必知道
  • 使用 volatile 关键字,会让所有线程都会读到变量的修改值
  • 在下面的代码中,running 是存在于堆内存的 t 对象中
  • 当线程 t1 开始运行的时候,会把 running 值从内存中读到 t1 线程的工作区,在运行过程中直接使用这个副本,
  • 并不会每次都去读取堆内存,这样,当主线程修改 running 的值之后,t1 线程感知不到,所以不会停止运行
  • 但是这可能是个错误。关于这个例子,在后面会专门花时间再讲
java
public class Demo {
    boolean running = true;
    public void test(){
        log.debug("test start...");
            while (running){
                // 1. 经过测试发现,当 while 语句的里面什么代码都没有时,程序不会停止,也就是 t1 无法感知到 主线程已经修改了 running 的值: demo.running = false;
                // 2. 但当 while 语句里面有代码时,比如 System.out.println("aaaa"); 这时候,t1 可以感知到 主线程已经修改了 running 的值: demo.running = false; 进而程序会退出循环,结束执行。
                // 3. 如果要解决 1. 中的问题,除了上面的第 2. 条,也可以加上 volatile 来修饰变量:volatile boolean running = true;
                // 4. 由 1. 和 2. 可以得出,“volatile 关键字,使一个变量在多个线程间可见” 的结论未必正确,因为 2. 中没有添加 volatile 关键词,也解决了 1. 中的问题
            }
        log.debug("test end...");
    }

    public static void main(String[] args) {
        Demo demo = new Demo();
        new Thread(demo :: test,"t1").start();

        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 这里更改 running 的值,目的是使 while() 语句条件得到终止
        demo.running = false;
    }
}

volatile 不能替代 synchronized(volatile 保证不了原子性)

synchronized 既可以保证原子性又保证了可见性

java
public class Demo {
    volatile int count = 0;
    public void test(){
        for (int i = 0; i < 10000; i++) {
            count ++;
        }
    }

    public static void main(String[] args) {
        Demo demo = new Demo();

        List<Thread> threads = new ArrayList();

        //new 10個线程
        for (int i = 0; i < 10; i++) {
            threads.add(new Thread(demo::test, "t-" + i));
        }

        //遍历这个10个线程  依次启动
        threads.forEach((o)->o.start());

        //等待10个线程执行完
        threads.forEach((o)->{
            try {
                o.join();
            } catch (Exception e) {
                e.printStackTrace();
            }
        });

        log.debug(demo.count+"");
    }
}

多个 AtomicInteger 类连续调用能否构成原子性?

答案是:不能构成原子性,即连续调用,程序会出问题。

因此,在写代码时,要避免连续调用 AtomicInteger 类的方法。但是,只调用一次,是可以的。

java
public class Demo {
    AtomicInteger count = new AtomicInteger(0);
    public void test(){
        for (int i = 0; i < 10000; i++) {
            // 在这里示例中,count.get() 和 count.incrementAndGet() 就不构成原子性,这两个是分别的两个 CAS 了,单个使用没问题,同时使用就出错了。
            if(count.get() < 1000){
                //count++
                count.incrementAndGet();
            }
        }
    }
    public static void main(String[] args) {
        Demo demo = new Demo();
        List<Thread> threads = new ArrayList();
        for (int i = 0; i < 10; i++) {
            threads.add(new Thread(demo::test, "thread-" + i));
        }

        threads.forEach((o)->o.start());
        threads.forEach((o)->{
            try {
                o.join();
            } catch (Exception e) {
                e.printStackTrace();
            }
        });

        log.debug(demo.count+"");
    }
}

一道面试题:

  • 实现一个容器,提供两个方法,add,size

  • 写两个线程,线程 1 添加 10 个元素到容器中,线程 2 实现监控元素的个数,

  • 当个数到 5 个时,线程 2 给出提示并结束

  • CountDownLatch

  • 使用 await 和 countdown 方法替代 wait 和 notify

  • CountDownLatch 不涉及锁定,当 count 的值为零时当前线程继续运行

  • 相当于是发令枪,运动员线程调用 await 等待,计数到 0 开始运行

  • 当不涉及同步,只是涉及线程通信的时候,用 synchronized 加 wait,notify 就显得太重了

java
public class Container5 {
    volatile List lists = new ArrayList();
    public void add(Object o){
        lists.add(o);
    }
    public int size(){
        return lists.size();
    }

    public static void main(String[] args) {
        Container5 c = new Container5();
        CountDownLatch latch = new CountDownLatch(1);

        new Thread(()->{
            log.debug("t2启动");
            if (c.size() != 5) {
                try {
                    //阻塞
                    latch.await();//准备
                } catch (Exception e) {
                    e.printStackTrace();
                }
                log.debug("t2结束");
            }
        }," t2").start();

        new Thread(()->{
            log.debug("t1启动");
            for (int i = 0; i < 10; i++) {
                c.add(new Object());
                log.debug("add " + i);
                if (c.size() == 5) {
                    latch.countDown();
                }
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }, "t1").start();
    }
}

死锁

同一个线程,一次性需要获得两把锁。另一个线程也需要获得两把锁,但获取的顺序和第一个线程相反,这样就形成了死锁,两个线程互相拿不到第二把锁,一直卡在这里等待。

java
public class LockTest {
    //定义两把锁
    static Object x = new Object();
    static Object y = new Object();

    public static void main(String[] args) {
        //线程1启动
        new Thread(()->{
            //获取x的锁
            synchronized (x){
                log.debug("locked x");
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (y){
                    log.debug("locked x");
                    log.debug("t1---------");
                }
            }

        },"t1").start();

        new Thread(()->{
            synchronized (y){
                log.debug("locked y");
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (x){
                    log.debug("locked x");
                    log.debug("t2---------");
                }
            }
        },"t2").start();
    }
}