在多线程并发的情况下,多线程同时访问的资源叫做临界资源(如变量、对象、文件等),当多个线程同时访问对象并要求操作相同资源时,其操作可能存在数据不一致或数据不完整的情况。为了避免这种情况的发生,需要采取同步互斥机制,以确保在某一时刻,方法内只允许一个线程对该资源进行操作,而其它线程只能等待。

概述

上面提到的处理方式其实是采用了序列化访问临界资源手段,也就是同一时刻,只能有一个线程去访问临界资源,等到该线程执行完以后,其它线程才可以对该临界资源进行操作。但由于多个线程在访问临界资源的过程是不可控的,因此需要采用同步机制来协同对象可变状态的访问。

这里的临界资源可称为共享、可变的资源,共享意味着该资源是可以由多个线程同时访问的,而可变指的是该资源在其生命周期内是可以被修改的。

在 Java 中可以通过synchronizedLock实现这种同步互斥访问机制,如果不采用这种同步机制的话,则在多线程环境下会出现线程安全问题,即多个线程同时访问同一个资源时,得到的运行结果往往与真实的结果不同。但这里需要注意一点的是:当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量属于每个线程私有的,因此不会共享,不会导致线程安全问题

本文主要介绍synchronized的实现方式,然后再给出一些使用synchronized的注意事项。

实现方式

Java 中的每一个对象都可以作为锁,synchronized 关键字可以用作以下三种形式:

  • 对于普通同步方法,锁是当前实例对象;
  • 对于静态同步方法,锁是当前类的 Class 对象;
  • 对于同步代码块,锁是 synchronized 括号里配置的对象。

也就是说,使用 synchronized 关键字来标记一个方法或者代码块时,当某个线程调用该对象的 synchronized 方法或者访问 synchronized 代码块时,这个线程便获得了该对象的锁,而其它线程如果想访问该方法或代码块时,则只能等待这个方法执行完毕或代码块执行完毕以后,这个线程才会释放该对象的锁,其它线程才能执行这个方法或代码块。

普通同步方法

下面演示一个在不使用 synchronized 关键字时,两个线程同时对临界资源进行操作的例子,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class SynchronizedTest {

    public static void main(String[] args) {
        final InsertData insertData = new InsertData();
        // 启动第一个线程
        new Thread(() -> insertData.insert(Thread.currentThread())).start();

        // 启动第二个线程
        new Thread() {
            @Override
            public void run() {
                insertData.insert(Thread.currentThread());
            }
        }.start();
    }
}


class InsertData {
    // 临界资源
    private List<Integer> list = new ArrayList<>();

    public void insert (Thread thread) {
        for (int i = 0; i < 10; i++) {
            System.out.println(thread.getName() + "正在插入数据: " + i);
            list.add(i);
        }
    }
}

输出结果如下:

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

通过结果可以看出,这两个线程在同时执行 insert 方法,而如果将 insert 方法用 synchronized 进行修饰,则会出现以下执行结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class InsertData {

    // 临界资源
    private  List<Integer> list = new ArrayList<>();
    // 使用 synchronized 修饰
    public synchronized void insert(Thread thread) {
        for (int i = 0; i < 10; i++) {
            System.out.println(thread.getName() + "正在插入数据: " + i);
            list.add(i);
        }
    }
}

执行结果如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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正在插入数据: 0
Thread-1正在插入数据: 1
Thread-1正在插入数据: 2
Thread-1正在插入数据: 3
Thread-1正在插入数据: 4
Thread-1正在插入数据: 5
Thread-1正在插入数据: 6
Thread-1正在插入数据: 7
Thread-1正在插入数据: 8
Thread-1正在插入数据: 9

不管执行多少次,都是第一个线程(Thread-0)先获得 insert 方法的锁对象,等到第一个线程执行完并释放锁以后,第二个线程(Thread-1)才会获取锁,继而执行 insert 方法。

synchronized 关键字让线程顺序执行成为了可能,但在使用时应注意以下几点

  • 当一个线程正在访问一个对象的 synchronized 方法时,那么其它线程不能访问该对象的其它 synchronized 方法。因为一个对象只有一把锁,当一个线程获取了该对象的锁之后,则其它线程无法获取该对象的锁,也就无法访问该对象的其它 synchronized 方法。
  • 当一个线程正在访问一个对象的 synchronized 方法时,那么其它线程可以访问该对象的非 synchronized 方法。因为该对象的非 synchronized 方法不需要获得该对象的锁,如果一个方法没有被 synchronized 关键字修饰,则说明它不会使用到临界资源,所以其它线程是可以访问到的。
  • 如果一个线程 A 需要访问对象 object1 的 synchronized 方法 fun1(),而另一个线程 B 需要访问对象 object2 的 synchronized 方法 fun1(),即使 object1 和 object2 是同一类型,那么也不会产生线程安全问题,因为它们访问的是不同的对象,所以不存在同步互斥问题。

同步代码块

可用以下形式让 synchronized 修饰同步代码块:

1
2
3
synchronized (object) {
    // 访问临界资源的代码
}

此时,锁的是 synchronized 括号里配置的对象,也就是 object。当然,括号里面的也可以是this,其表示获取当前对象的锁。但需要注意的是:普通的同步方法与 synchronized(this) 同步代码块是互斥的,因为它们锁的是同一个对象,而普通的同步方法与 synchronized(非 this) 同步代码块是异步的,因为它们锁的是不同的对象。

括号里面是this监视器的情况:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class InsertData {
    // 临界资源
    private List<Integer> list = new ArrayList<>();

    public void insert(Thread thread) {
        synchronized (this) {
            for (int i = 0; i < 10; i++) {
                System.out.println(thread.getName() + "正在插入数据: " + i);
                list.add(i);
            }
        }
    }
}

括号里面是对象监视器的情况:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class InsertData {
    // 临界资源
    private List<Integer> list = new ArrayList<>();
    private Object object = new Object();

    public void insert(Thread thread) {
        synchronized (object) {
            for (int i = 0; i < 10; i++) {
                System.out.println(thread.getName() + "正在插入数据: " + i);
                list.add(i);
            }
        }
    }
}

对比普通同步方法和同步代码块可以看到,使用 synchronized 修饰的代码块比 synchronized 修饰的同步方法在锁粒度方面要细一些,也就是说锁定对象的范文更小了,从而减小了锁发生冲突的可能性,继而提高了系统的并发能力。

如果我们想要在一个方法中将其中的一部分代码进行同步的话,如果此时使用 synchronized 来修饰整个方法,则会影响程序的执行效率,而使用同步代码快只对这部分代码进行同步的话,效果会好一些。

从 JVM 层面来看,synchronized 修饰代码块的底层是通过进入和退出 Monitor 对象来实现的,即 monitorenter 和 monitorexit。monitorenter 指令是在编译后插入到同步代码块的开始位置,而 monitorexit 插入到方法结束处或异常处,JVM 要保证每个 monitorenter 必须有对应的 monitorexit 与之配对。任何对象都有一个 monitor 与之关联,当一个 monitor 被持有后,它将处于锁定状态。线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 monitor 的所有权,即尝试获得对象的锁。

静态同步方法

静态方法(被 static 修饰的方法)与当前类有关,因此每个类也会有一个锁,而静态的 synchronized 方法是以 Class 对象作为锁。而且,它还可以用来控制对 static 数据成员的并发访问,但需要注意的是,static 数据成员不属于任何一个对象,它属于类成员。

同时也需要注意的是,如果一个线程执行一个对象的非 static synchronized 方法,另外一个线程想要执行这个对象所属类的 static synchronized 方法时,是不会发生互斥的现象。因为访问 static synchronized 方法占用的是类的锁,而访问非 static synchronized 方法占用的是对象的锁,所以不存在互斥的现象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class SynchronizedTest2 {

    public static void main(String[] args) {

        final InsertData2 insertData = new InsertData2();
        // 分别启动两个线程
        new Thread(() -> insertData.insert1()).start();
        new Thread() {
            @Override
            public void run() {
                insertData.insert2();
            }
        }.start();
    }
}

class InsertData2 {
    // 非 static 修饰的 synchronized 方法
    public synchronized void insert1() {
        System.out.println("执行 insert1");
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("执行 insert1 完毕");
    }

    // static 修饰的 synchronized 方法
    public static synchronized void insert2() {
        System.out.println("执行 insert2");
        System.out.println("执行 insert2 完毕");
    }
}

输出结果如下所示:

1
2
3
4
执行 insert1
执行 insert2
执行 insert2 完毕
执行 insert1 完毕

可以看到,第一个线程执行的是 insert1 方法,不会导致第二个线程执行 insert2 方法时发生阻塞现象。最后需要注意的是,对于 synchronized 修饰的同步方法和 synchronized 修饰的同步代码快来说,当出现异常时,JVM 会自动释放当前线程占用的锁,因此不会出现由于异常而导致的死锁现象。

可重入锁

一般情况下,当某个线程请求一个由其它线程只有的锁时,发起请求的线程就会阻塞。而如果某个线程试图获得一个已经由它自己持有的锁时,那么这个请求就会成功,因此也称为可重入锁。Java 中的 ReentrantLock 和 synchronized 都属于可重入锁。其作用是可以在一定上避免死锁。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class SynchronizedTest3 implements Runnable {

    public synchronized void get() {
        System.out.println(Thread.currentThread().getName());
        // 注意这里调用了被 synchronized 修饰的 set 方法
        set();
    }

    public synchronized void set() {
        System.out.println(Thread.currentThread().getName());
    }

    @Override
    public void run() {
        System.out.println("run start...");
        get();
        System.out.println("run end...");
    }

    public static void main(String[] args) {
        SynchronizedTest3 test = new SynchronizedTest3();
        new Thread(test, "Thread-0").start();
        new Thread(test, "Thread-1").start();
        new Thread(test, "Thread-2").start();
    }
}

输出结果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
run start...
Thread-0
Thread-0
run end...
run start...
Thread-2
Thread-2
run end...
run start...
Thread-1
Thread-1
run end...

同步代码块的注意事项

需要注意的是:由于存在字符串常量池的原因,在使用同步代码块时不能使用 String 作为锁的对象,可以实例化 Object 对象,因为它并不会被放入缓存中。

由于字符串是存储在常量池中的,有两种类型的字符串数据会存储在常量池中:

  • 编译期就可以确定的字符串,也就是字面量。比如String str1 = "abc123";,而对于String str2 = "1" + A.getDataFromDB() + "c";来说,其中的1c都是可以在编译期确定的字符串,而A.getDataFromDB()无法在编译期确定,因此它们是分配在堆上的。
  • 使用 String 的 intern() 方法操作的字符串。比如String str3 = A.getDataFromDB().intern();,虽然A.getDataFromDB()是在堆上拿到的字符串,但由于使用了intern()方法,因此每次会将A.getDataFromDB()的结果写入常量池中。

因此,常量池在操作 String 类型的数据时,在每次获取数据的值时,如果常量池中已经有了,则直接使用常量池中的值;如果常量池中没有的话,则会将数据写入常量池中并返回常量池中的数据。

此外,synchronized 锁的是对象而非引用,即它是一种对象锁,作用的目标是对象,而不是将一段代码或方法当做锁,或者说锁的粒度是处于对象级别的。哪个线程先执行带有 synchronized 关键字的方法,哪个线程就持有该方法所属对象的锁,其它线程只能处于等待状态。

既然是对象锁,那就肯定和对象有关,所以多线程访问的必须是同一个对象。如果多线程访问的是多个对象,那么 JVM 就会创建多个锁,每个对象持有的是自己的锁,则会分别执行自己的代码。

参考