线程间的同步与通信(1)——同步代码块Synchronized

前言

同步代码块(Synchronized Block) 是java中最基础的实现线程间的同步与通信的机制之一,本篇我们将对同步代码块以及监视器锁的概念进行讨论。

系列文章目录

什么是同步代码块(Synchronized Block)

同步代码块简单来说就是将一段代码用一把给锁起来, 只有获得了这把锁的线程才访问, 并且同一时刻, 只有一个线程能持有这把锁, 这样就保证了同一时刻只有一个线程能执行被锁住的代码.

这里有两个关键字需要注意: 一段代码.

一段代码

一般来说, 由 synchronized 锁住的代码都是拿{}括起来的代码块:

synchronized(this) {
    //由锁保护的代码
}

但值得注意的是, synchronized 也可以用来修饰一个方法, 则对应的被锁保护的一段代码很自然就是整个方法体.

public class Foo {
    public synchronized void doSomething() {
        // 由锁保护的代码
    }
}

其实锁这个东西说起来很抽象, 你可以就把它想象成现实中的锁, 所以它只不过是一块令牌, 一把尚方宝剑, 它是木头做的还是金属做的并不重要, 你可以拿任何东西当作锁, 重要的是它代表的含义: 谁持有它, 谁就有独立访问临界区(即上面所说的一段代码)的权利.

在java中, 我们可以拿一个对象当作锁.

这里引用<>中的一段话:

每个java对象都可以用做一个实现同步的锁, 这些锁被称为内置锁(Intrinsic Lock)或者监视器锁(Monitor Lock). 线程在进入同步代码块之前会自动获得锁, 并且在退出同步代码块时自动释放锁.

获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法.

所以, synchronized 同步代码块的标准写法应该是:

synchronized(reference-to-lock) {
    //临界区
}

其中, 括号里面的reference-to-lock就是锁的引用, 它只要指向一个Java对象就行, 你可以自己随便new一个不相关的对象, 将它作为锁放进去, 也可以像之前的例子一样, 直接使用this, 代表使用当前对象作为锁.

有的同学就要问了, 我们前面说可以用synchronized修饰一个方法, 并且也知道对应的由锁保护的代码块就是整个方法体, 但是, 它的锁是什么呢?
要回答这个问题,首先要区分synchronized 所修饰的方法是否是静态方法:

如果 synchronized所修饰的是静态方法, 则其所用的锁为 Class对象
如果 synchronized所修饰的是非静态方法, 则其所用的锁为 方法调用所在的对象

当使用synchronized 修饰非静态方法时, 以下两种写法是等价的:

//写法1
public synchronized void doSomething() {
    // 由锁保护的代码
}

//写法2
public void doSomething() {
    synchronized(this) {
        // 由锁保护的代码
    }
}

到底拿什么锁住了同步代码块

同步代码块中最难理解的部分就是拿什么作为了锁, 上面我们已经提到了三个 this, Class对象, 方法调用所在的对象, 并且我们也说明了可以拿任何java对象作为锁.

this方法调用所在的对象

这两个其实是一个意思, 我们需要特别注意的是, 一个Class可以有多个实例(Instance), 每一个Instance都可以作为锁, 不同Instance就是不同的锁, 同一个Instance就是同一个锁, this方法调用所在的对象 指代的都是调用这个同步代码块的对象.

这么说可能比较抽象, 我们直接上例子: (以下例子转载自博客Java中Synchronized的用法)

class SyncThread implements Runnable {
    private static int count;

    public SyncThread() {
        count = 0;
    }

    public  void run() {
        synchronized(this) {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    
    public static void main(String[] args) {
        SyncThread syncThread = new SyncThread();
        //线程1和线程2使用了SyncThread类的同一个对象实例
        //因此, 这两个线程中的synchronized(this), 持有的是同一把锁
        Thread thread1 = new Thread(syncThread, "SyncThread1");
        Thread thread2 = new Thread(syncThread, "SyncThread2");
        thread1.start();
        thread2.start();
    }
}

运行结果:

SyncThread1:0
SyncThread1:1
SyncThread1:2
SyncThread1:3
SyncThread1:4
SyncThread2:5
SyncThread2:6
SyncThread2:7
SyncThread2:8
SyncThread2:9

这里两个线程SyncThread1SyncThread2 持有同一个对象syncThread的锁, 因此同一时刻, 只有一个线程能访问同步代码块, 线程SyncThread2 只有等 SyncThread1 执行完同步代码块后, SyncThread1线程自动释放了锁, 随后 SyncThread2才能获取同一把锁, 进入同步代码块.

我们也可以修改一下main函数, 让两个线程持有同一Class对象的不同实例的锁:

public static void main(String[] args) {
    Thread thread1 = new Thread(new SyncThread(), "SyncThread1");
    Thread thread2 = new Thread(new SyncThread(), "SyncThread2");
    thread1.start();
    thread2.start();
}

上面这段等价于:

public static void main(String[] args) {
    SyncThread syncThread1 = new SyncThread();
    SyncThread syncThread2 = new SyncThread();
    Thread thread1 = new Thread(syncThread1, "SyncThread1");
    Thread thread2 = new Thread(syncThread2, "SyncThread2");
    thread1.start();
    thread2.start();
}

运行结果:

SyncThread1:0
SyncThread2:1
SyncThread1:2
SyncThread2:3
SyncThread1:4
SyncThread2:5
SyncThread1:6
SyncThread2:7
SyncThread1:8
SyncThread2:9

可见, 两个线程这次都能访问同步代码块, 这是因为线程1执行的是syncThread1对象的同步代码块, 线程2执行的是syncThread2的同步代码块, 虽然这两个同步代码块一样, 但是他们在不同的对象实例里面, 即虽然它们都用this作为锁, 但是this指代的对象在这两个线程中不是同一个对象, 两个线程各自都能获得锁, 因此各自都能执行这一段同步代码块.

这告诉我们, 当一段代码用同步代码块包起来的时候, 并不绝对意味着这段代码同一时刻只能由一个线程访问, 这种情况只发生在多个线程访问的是同一个Instance, 也就是说, 多个线程请求的是同一把锁.

再回顾我们上面两个例子, 第一个例子中, 两个线程使用的是同一个对象实例, 他们需要同一把对象锁 syncThread,
第二个例子中, 两个线程分别使用了一个对象实例, 他们分别请求的是自己访问的对象实例的锁syncThread1, syncThread2, 因此都能访问同步代码块.

导致不同线程可以同时访问同步代码块的最根本原因就是我们使用的是当前实例对象锁(this), 因为类的实例可以有多个, 这导致了同步代码块散布在类的多个实例中, 虽然同一个实例中的同步代码块只能由持有锁的单个线程访问(this对象锁保护), 但是我们可以每个线程访问自己的对象实例, 而每一个对象实例的同步代码块都是一致的, 这就间接导致了多个线程同时访问了"同一个"同步代码块.

上面这种情况在某些条件下是没有问题的, 例如同步代码块中不存在对静态变量(共享的状态量)的修改.

但是, 对于上面的例子, 这样的情况明显违背了我们加同步代码块的初衷.

要解决上面的情况, 一种可行的办法就是像第一个例子一样, 多个线程使用同一个对象实例, 例如在单例模式下, 本身就只有一个对象实例, 所以多个线程必将请求同一把锁, 从而实现同步访问.

另一种方法就是我们下面要讲的: 使用Class锁.

使用Class级别锁

前面我们提到:

如果 synchronized所修饰的是静态方法, 则其所用的锁为 Class对象

这是因为静态方法是属于类的而不属于对象的, 因此synchronized修饰的静态方法锁定的是这个类的所有对象。我们来看下面一个例子(以下例子同样转载自博客Java中Synchronized的用法):

class SyncThread implements Runnable {
    private static int count;

    public SyncThread() {
        count = 0;
    }

    // synchronized 关键字加在一个静态方法上
    public synchronized static void staticMethod() {
        for (int i = 0; i < 5; i ++) {
            try {
                System.out.println(Thread.currentThread().getName() + ":" + (count++));
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    public void run() {
        staticMethod();
    }

    public static void main(String[] args) {
        SyncThread syncThread1 = new SyncThread();
        SyncThread syncThread2 = new SyncThread();
        Thread thread1 = new Thread(syncThread1, "SyncThread1");
        Thread thread2 = new Thread(syncThread2, "SyncThread2");
        thread1.start();
        thread2.start();
    }
}

运行结果:

SyncThread1:0
SyncThread1:1
SyncThread1:2
SyncThread1:3
SyncThread1:4
SyncThread2:5
SyncThread2:6
SyncThread2:7
SyncThread2:8
SyncThread2:9

可见, 静态方法锁定了类的所有对象, 用我们之前的话来说, 如果说"因为类的实例可以有多个, 这导致了同步代码块散布在类的多个实例中", 那么类的静态方法就是阻止同步代码块散布在类的实例中, 因为类的静态方法只属于类本身.

其实, 上面的例子的本质就是拿Class对象作为锁, 我们前面也提到了, 可以拿任何对象作为锁, 如果我们直接拿类的Class对象作为锁, 同样可以保证所以线程请求的都是同一把锁, 因为Class对象只有一个.

类锁实际上是通过对象锁实现的,即类的 Class 对象锁。每个类只有一个 Class 对象,所以每个类只有一个类锁。
class SyncThread implements Runnable {
    private static int count;

    public SyncThread() {
        count = 0;
    }

    public void run() {
        // 这里直接拿Class对象作为锁
        synchronized(SyncThread.class) {
            for (int i = 0; i < 5; i++) {
                try {
                    System.out.println(Thread.currentThread().getName() + ":" + (count++));
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) {
        SyncThread syncThread1 = new SyncThread();
        SyncThread syncThread2 = new SyncThread();
        Thread thread1 = new Thread(syncThread1, "SyncThread1");
        Thread thread2 = new Thread(syncThread2, "SyncThread2");
        thread1.start();
        thread2.start();
    }
}

这样所得到的结果与上面的类的静态方法加锁是一致的。

几点补充

其实到这里, 重要的部分已经讲完了, 下面补充说明几点:

(1) 当一个线程访问对象的一个synchronized(this)同步代码块时,另一个线程仍然可以访问该对象中的非synchronized(this)同步代码块。

这个结论是显而易见的, 在没有加锁的情况下, 所有的线程都可以自由地访问对象中的代码, 而synchronized关键字只是限制了线程对于已经加锁的同步代码块的访问, 并不会对其他代码做限制.
这里也提示我们:

同步代码块应该越短小越好

(2) 当一个线程访问object的一个synchronized(this)同步代码块时,其他线程对object中所有其它synchronized(this)同步代码块的访问将被阻塞。

这个结论也是显而易见的, 因为synchronized(this)拿的都是当前对象的锁, 如果一个线程已经进入了一个同步代码块, 说明它已经拿到了锁, 而访问同一个object中的其他同步代码块同样需要当前对象的锁, 所以它们会被阻塞.

(3) synchronized关键字不能继承。

对于父类中用synchronized 修饰的方法,子类在覆盖该方法时,默认情况下不是同步的,必须显式的使用 synchronized 关键字修饰才行, 当然子类也可以直接调用父类的方法, 这样就间接实现了同步.

(4) 在定义接口方法时不能使用synchronized关键字。
(5) 构造方法不能使用synchronized关键字,但可以使用synchronized代码块来进行同步。
(6) 离开同步代码块后,所获得的锁会被自动释放。

总结

  • synchronized关键字通过一把锁住一段代码, 使得线程只有在持有锁的时候才能访问这段代码
  • 任何java对象都可以作为这把锁
  • 可以在synchronized后面用()显式的指定锁. 也可以直接作用在方法上

    • 作用于普通方法时, 相当于以this对象作为锁, 此时同步代码块散布于类的所有实例中, 每一个实例的同步代码块的锁 为该实例对象自身。
    • 作用于静态方法时, 相当于以Class对象作为锁, 此时对象的所有实例只能争抢同一把锁。
  • 内置锁的一个重要的特性是当离开同步代码块之后, 会自动释放锁,而其他的高级锁(如ReentrantLock)需要显式释放锁。

思考题

前面我们说明了synchronized 的使用方法,但对一些底层的细节并不了解,如:

  1. 前面说“获得内置锁的唯一途径就是进入由这个锁保护的同步代码块或方法.”, 这句话看上去很有道理,其实是废话,同步代码块究竟是怎么获得锁的?
  2. 我们说,JAVA中任何对象都可以作为锁,那么锁信息是怎么被记录和存储的?
  3. 为什么代码离开了同步代码块锁就被释放了,谁释放了锁,怎样叫释放了锁?

这些问题,我们后续的文章再研究。

(完)

查看更多系列文章:系列文章目录

你可能感兴趣的:(线程间的同步与通信(1)——同步代码块Synchronized)