聊聊并发:(四)线程安全与同步之synchronized分析

前言

在上一篇中,聊聊并发:(三)wait、sleep、notify、notifyAll分析

我们介绍了wait、sleep、notify、notifyAll,本篇中,我们将一起来学习一下Java中的关键字synchronized的用法和原理。

synchronized介绍

synchronized是Java中的关键字,是一种同步锁。在多线程开发中经常会使用到这个关键字,其主要作用是可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块,同时保证一个线程操作的数据的变化被其他线程所看到。

synchronized可以使用在代码块和方法中,根据synchronized用的位置可以有这些使用场景:

方法

实例方法

synchronized可以在实例方法中使用,这时候,锁住的该类的实例对象,即多个线程同时访问同一个new出来的对象时候,该方法会进行阻塞。

例如:

public synchronized void doSomething() {
    .....
}

静态方法

synchronized也可以在静态方法中使用,这时候,锁住的是类对象,与实例方法不同,无论new出多少个对象,多个线程同时访问的时候,都会进行阻塞,因为它们同属于一个类。

例如:

public static synchronized void doSomething() {
    .....
}

代码块

实例对象

与实例方法一样,这时候,锁住的该类的实例对象,即多个线程同时访问同一个new出来的对象时候,该方法会进行阻塞。

例如:

synchronized(this) {
    
}

Class对象

与静态方法一样,锁住的是类对象,与实例方法不同,无论new出多少个对象,多个线程同时访问的时候,都会进行阻塞,因为它们同属于一个类。

例如:

synchronized(SynchronizedThreadDemo.class) {
    
}

任意的实例对象

与实例对象类似,只不过这里可以传入任意的实例对象,多个线程访问的时候,如果是同一个实例对象,都会进行阻塞。

例如:

Object lock = new Object();
synchronized(lock) {
    .....
}

接下来,我们先看一个多线程场景下的例子:

public class NoSynchronizedThreadDemo {

    private static int counter = 0;

    public static void main(String[] agrs) {
        CountDownLatch countDownLatch = new CountDownLatch(100);
        for (int i = 0; i < 100; i++) {
            Thread thread = new Thread(() -> {
                counter++;
                countDownLatch.countDown();
            });
            thread.start();
        }
        try {
            countDownLatch.await();
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println(counter);
    }
}

执行结果:

99

上面的例子中,我们启动100个线程,每个线程对计数器自增,等待全部线程执行完毕后,输出自增的结果,经过多次执行后,发现其中有一次的输出是“99”,而不是“100”,这里就涉及到了多线程场景下线程不安全的问题,由于“i++”操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,造成自增值不正确的问题,而synchronized就是为了解决这个问题存在的。

同样上面的方法中,我们调整一下,加入synchronized,再看看效果:

public class SynchronizedThreadDemo {

    private static int counter = 0;

    public static void main(String[] agrs) {
        CountDownLatch countDownLatch = new CountDownLatch(100);
        for (int i = 0; i < 100; i++) {
            Thread thread = new Thread(() -> {
                synchronized (SynchronizedThreadDemo.class) {
                    counter++;
                }
                countDownLatch.countDown();
            });
            thread.start();
        }
        try {
            countDownLatch.await();
        } catch (Exception e) {
            e.printStackTrace();
        }
        System.out.println(counter);
    }
}

执行结果:

100

经过多次执行,结果均为“100”,证明了synchronized使线程不安全的方法变得安全了,这就是它的作用。

那么上面提到了synchronized这么多种用法,那么关于使用synchronized关键字,是用在方法上面为好,还是用在一个代码块上面为好?

答案就是使用锁定代码块为更好。因为在实例方法上进行同步,会锁住整个对象,全部线程请求这个对象的这个方法时,都会进行阻塞,而这个方法中,可能只有一小部分的逻辑是需要同步进行控制的,这样会大大减少访问吞吐量。

所以,尽量减少同步代码块的范围,只对需要进行同步的代码进行同步,是更加好的选择

synchronized实现机制

synchronized的实现是基于对象锁机制来实现的,我们先看一个简单的例子:

public class SynchronizedDemo {

    private static int counter = 0;

    public static void main(String[] agrs) {
        SynchronizedDemo synchronizedDemo = new SynchronizedDemo();
        synchronizedDemo.synMethod();
    }

    public void synMethod() {
        synchronized (this) {
            counter++;
        }
        System.out.println(counter);
    }
}

上面的示例中是一段简单的代码,其中synMethod()中有synchronized的静态代码块,我们通过javap命令查看其class文件的字节码:
聊聊并发:(四)线程安全与同步之synchronized分析_第1张图片
其中标红的部分,就是我们重点需要关注的部分,可以看到,执行同步代码块后首先要先执行monitorenter指令,退出的时候monitorexit指令。通过分析之后可以看出,使用synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor

任意一个对象都拥有自己的monitor监视器,当这个对象由同步块或者这个对象的同步方法调用时,执行方法的线程必须先获取该对象的监视器才能进入同步块和同步方法,如果没有获取到监视器的线程将会被阻塞在同步块和同步方法的入口处,进入到BLOCKED状态。

在这里引用The Java® Virtual Machine Specification中对于同步方法和同步代码块的实现原理的介绍:

方法级的同步是隐式的。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,
如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁。
这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。值得注意的是,如果在方法执行过程中,发生了异常,
并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。
同步代码块使用monitorenter和monitorexit两个指令实现。可以把执行monitorenter指令理解为加锁,
执行monitorexit理解为释放锁。 每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,
当一个线程获得锁(执行monitorenter)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,
计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。

这里关于monitor监视器,我们在深入聊一下,每个对象都存在着一个monitor与之关联,对象与其 monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。

在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入_EntryList集合,当线程获取到对象的monitor后进入_Owner区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用wait()方法,将释放当前持有的monitorowner变量恢复为nullcount自减1,同时该线程进入WaitSet集合中等待被唤醒。

若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)
聊聊并发:(四)线程安全与同步之synchronized分析_第2张图片

JVM对synchronized的优化

上面提到了,synchronized的实现机制是基于对象锁实现的,同一时刻只有一个线程可以拿到当前对象的对象锁,这时候你可以也有疑问,这样的话,synchronized是否效率太差了?事实上,JVM在1.6版本之后,对synchronized进行了优化。

锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。对象的MarkWord变化为下图:

聊聊并发:(四)线程安全与同步之synchronized分析_第3张图片

偏向锁

偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁,会涉及到一些CAS操作,耗时的代价而引入偏向锁。

偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。

所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。

轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

重量锁

一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

锁的比较

聊聊并发:(四)线程安全与同步之synchronized分析_第4张图片

synchronized的可重入性

前面提到过,synchronized是有互斥性的,当多个线程同时访问同一个对象的同步方法时,会进行阻塞,但是当但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。

我们来看一个例子:

public class SynchronizedReentrantDemo {

    public static void main(String[] agrs) {
        Thread thread1 = new Thread(new ReentrantExecutor());
        Thread thread2 = new Thread(new ReentrantExecutor());
        Thread thread3 = new Thread(new ReentrantExecutor());
        thread1.start();
        thread2.start();
        thread3.start();
    }
}

class ReentrantExecutor implements Runnable {

    private static int counter = 0;

    @Override
    public void run() {
        for (int i = 1; i < 100; i++) {
            synchronized (this) {
                counter++;
                System.out.println("current thread : " + Thread.currentThread().getName());
                reentrantMethod();
            }
        }
    }

    public synchronized void reentrantMethod() {
        System.out.println("start execute reentrantMethod...");
        System.out.println("current thread : " + Thread.currentThread().getName());
        System.out.println("end execute reentrantMethod...");
    }
}

执行结果:

current thread : Thread-0
start execute reentrantMethod...
current thread : Thread-0
end execute reentrantMethod...
current thread : Thread-0
start execute reentrantMethod...
current thread : Thread-0
end execute reentrantMethod...
current thread : Thread-0
start execute reentrantMethod...
current thread : Thread-0
end execute reentrantMethod...
current thread : Thread-0
start execute reentrantMethod...
current thread : Thread-0
end execute reentrantMethod...
current thread : Thread-0
start execute reentrantMethod...
current thread : Thread-0
end execute reentrantMethod...
current thread : Thread-0
start execute reentrantMethod...
current thread : Thread-0
end execute reentrantMethod...
current thread : Thread-0
start execute reentrantMethod...
current thread : Thread-0
end execute reentrantMethod...
current thread : Thread-0
start execute reentrantMethod...
current thread : Thread-1
start execute reentrantMethod...
current thread : Thread-1
end execute reentrantMethod...
current thread : Thread-1
start execute reentrantMethod...
current thread : Thread-1
end execute reentrantMethod...
current thread : Thread-1
start execute reentrantMethod...
current thread : Thread-1
end execute reentrantMethod...

执行结果较长,我这里只摘取一小部分,从执行的结果我们可以看到,synchronized是具有可重入性的,同一个线程拿到对象的对象锁后,再次进入同步代码块中的时候,是不需要再次拿锁的。

结语

本文,我们介绍了synchronized的使用方法与实现机制,其实关于synchronized的实现还是有很多点可以进行深入研究的,本文中没有太深入的进行分析,有兴趣的读者可以自行研究了解一下,下一篇,我们将分析一下Java中另一个关键字volatile的用法及机制,敬请期待下一篇《线程安全与同步之volatile分析》。

本文参考:

https://blog.csdn.net/javazejian/article/details/72828483

https://juejin.im/post/5ae6dc04f265da0ba351d3ff

《深入分析Java虚拟机》

更多Java干货文章请关注我的个人微信公众号:老宣与你聊Java

这里写图片描述

你可能感兴趣的:(Java多线程开发,聊聊Java并发,synchronized,多线程同步,线程安全,synchronized用法)