Java多线程 synchronized关键字使用及原理

190 阅读7分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第10天,点击查看活动详情

有JavaSE基础的同学应该对synchronized并不陌生,只需要在一个方法上或者用它包裹住一块代码块,就能自动加锁实现线程同步了,如下

使用

应用在方法上

static class _Test {
    public synchronized void print() {
        System.out.println(Thread.currentThread().getName() + "ing...");
    }
}

@Test
public void _synchronized() throws InterruptedException {
    _Test test = new _Test();
    Thread t1 = new Thread(test::print, "t1");
    Thread t2 = new Thread(test::print, "t2");
    Thread t3 = new Thread(test::print, "t3");
    Thread t4 = new Thread(test::print, "t4");

    t1.start();
    t2.start();
    t3.start();
    t4.start();
}

输出结果

t1ing...
t3ing...
t2ing...
t4ing...

若拿掉synchronized,输出结果如下

t2ing...
t1ing...
t3ing...
t4ing...

很显然,synchronized加的是一把同步锁,一次只能有一个线程获取同步资源,锁未释放时其他线程只能阻塞,当synchronized应用在对象方法上时,锁对象默认为this

应用在静态方法上

与上一例使用相同,不过此时锁对象为类本身,无论有多少线程访问,锁只有一把,不同于对象方法,有几个对象就有几把锁

同步代码块

print()方法改为如下形式

public synchronized void print() {
    synchronized (this){
        System.out.println(Thread.currentThread().getName() + "ing...");
    }
}

这是同步代码块写法,不同于修饰方法,这里需要在括号内写上锁的具体对象,且不能为null,这里锁了this,所以效果与修饰对象方法相同

static class _Test {

    public void print(Object object) {

        synchronized (object) {
            if(Thread.currentThread().getName().equals("t1")){
                try {
                    sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            System.out.println(Thread.currentThread().getName() + "ing...");
        }
    }
}

@Test
public void _synchronized() throws InterruptedException {
    _Test test = new _Test();
    Object o  = new Object();
    Object o2  = new Object();
    Thread t1 = new Thread(()->test.print(o), "t1");
    Thread t2 = new Thread(()->test.print(o2), "t2");

    t1.start();
    t2.start();

    sleep(3000);
}

这里将形参作为锁对象,传入了两个不同的对象,如果是线程1则先睡眠1s,所以输出结果如下

t2ing...
t1ing...

原理

synchronized的执行流程大致如下

image.png

重量级锁

在操作系统中,锁不叫锁,叫管程(monitor)亦或是信号量(semaphere),而JDK是选择管程来实现锁机制的

synchronized在JDK1.6前就是默认就是重量级锁,重量级锁也就是互斥锁也就是管程(Monitor),也就是以上讲的概念:一个线程获取锁后,CPU会阻塞其他线程尝试获取锁的线程

Monitor上会持有一个计数器,当计数器为零时则表示锁是可以获取的,否则获取就会阻塞

在Java中,synchronized选择一个对象作为锁对象,会在对象头中存储一个指向Monitor对象的指针,当线程想获取锁时,则会寻找到对象关联的Monitor对象,查询其计数器是否为0,然后执行不同的操作,这也就解释了为什么synchronized选择的锁对象不能为空

所以执行流程可以更新为

image.png

注意,在JVM虚拟机中一个完整的Java对象所占的内存里,不仅仅是存储了对象本身实例的信息,还存在着一个对象头用来存储其他的信息等等

可重入性

那么现在有一个疑问,那当我执行一个同步对象方法时,里面调用了另一个同步对象方法时,会发生什么呢?

带着这个疑问我们来看看以以下代码

static class _Test {

    public synchronized void print() {
        System.out.println(Thread.currentThread().getName()+"ing...");
        print2();
    }

    public synchronized void print2() {
        System.out.println(Thread.currentThread().getName()+"ing again...");
        print3();
    }

    public synchronized void print3() {
        System.out.println(Thread.currentThread().getName()+"ing again again");
    }
}

@Test
public void _synchronized() throws InterruptedException {
    _Test test = new _Test();
    Thread t1 = new Thread(test::print, "t1");
    Thread t2 = new Thread(test::print, "t2");

    t1.start();
    t2.start();

    sleep(3000);
}

输出结果

t1ing...
t1ing again...
t1ing again again
t2ing...
t2ing again...
t2ing again again

为什么顺利执行完毕了呢,按照我们的理解,当t1进入print()的时候,自动为test对象加上了锁,此时Monitor计数器为1,执行完输出后,跳转到print2()的时候,由于并未释放锁,所以计数器依旧为1,所以也无法获取到print2()的锁,应该造成线程的死锁自身阻塞在那

这就要引入可重入性的概念,什么是可重入性

若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的

可重入锁,也叫做递归锁,是指在一个线程中可以多次获取同一把锁,比如:一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法【即可重入】,而无需重新获得锁

synchronized只是具有可重入性,但它并不是可重入锁(ReentrantLock),我们可以使用Lock lock = new ReentrantLock()创建可重入锁

所以当t1进入print()时,获取了test对象这把锁,所以Monitor计数器++
print()内部调用print2()时,由于锁与print()是同一把,所以无需再次获取也无需阻塞,此时Monitor计数器再次++
print3()也是如此,此时计数器为3,然后当print3()执行完毕,计数器--,回到print()执行完毕时,计数器刚好减为0,所以释放了锁
CPU便唤醒了在同步队列中阻塞的t2线程,流程大概如下

image.png

公平锁和非公平锁

再来看一段代码

static class _Test {

    public synchronized void print() {
        synchronized (this) {
            try {
                sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            System.out.println(Thread.currentThread().getName() + "ing...");
        }
    }
}

@Test
public void _synchronized() throws InterruptedException {
    _Test test = new _Test();
    Thread t1 = new Thread(test::print, "t1");
    Thread t2 = new Thread(test::print, "t2");
    Thread t3 = new Thread(test::print, "t3");
    Thread t4 = new Thread(test::print, "t4");

    t1.start();
    t2.start();
    t3.start();
    t4.start();

    sleep(3000);
}

输出结果:

t1ing...
t3ing...
t4ing...
t2ing...

也许有同学迷惑了,为什么在线程内睡眠了100ms,执行结果就不是顺序的了呢?这就是公平锁和非公平锁的概念了

公平锁:多个线程按照执行顺序去访问锁,直接进入同步队列,等待锁释放依次出队

  • 优点:所有入队的线程最终都能获取到锁 缺点:CPU需要依次唤醒所有阻塞的线程,这涉及到用户态和内核态的切换

非公平锁:多个线程访问锁时,会直接尝试插队获取,而不是直接入队,若锁被占用才会入队

  • 优点:可以减少CPU唤醒线程的次数,减少开销 缺点:如上所示,执行顺序被打乱了,而且如果当前并发越高,则越有可能一些线程始终无法获取到锁,造成所为的饥饿问题

冷知识:sleep()方法不会释放锁,而wait(),await()方法会

synchronized就是非公平锁

所以如上代码,当t1获取到锁后,进入睡眠,并占有锁,所以t2直接入队了
而当t3执行时t1刚好睡眠完,直接再次获取锁,t2依旧在排队
t3执行完后t4也来了,t2又被插队了,最后当t4也执行完毕了,才轮到可怜的t2

Synchronized的新生

在JDK1.6之前,synchronized一直是重量级方法,因为它本身是重量级锁,线程的阻塞和唤醒都会对CPU造成不低负载,而在JDK1.6之后,对锁进行了优化,如轻量级锁,自旋锁,适应性自旋锁等等,所以同学们使用synchronized时不要有心理负担

参考书籍+推荐阅读

《深入理解JVM虚拟机》