Java并发-深入理解synchronized

Java基础

浏览数:356

2019-8-24

synchronized基础用法

synchronized的三种应用方式:

  1. 作用于实例方法,当前实例加锁,进入同步代码前要获得当前实例的锁;
  2. 作用于静态方法,当前类加锁,进去同步代码前要获得当前类对象的锁;
  3. 作用于代码块,这需要指定加锁的对象,对所给的指定对象加锁,进入同步代码前要获得指定对象的锁。

用法总结如下:

注:

  1. 无论synchronized关键字加在方法上还是对象上,如果它作用的对象是非静态的,则它取得的锁是对象;如果synchronized作用的对象是一个静态方法或一个类,则它取得的锁是对类,该类所有的对象同一把锁;
  2. 每个对象只有一个锁(lock)与之相关联,谁拿到这个锁谁就可以运行它所控制的那段代码;
  3. 无论是方法正常执行完毕或者方法抛出异常,都会释放锁;
  4. synchronized不可以被继承,父类某个方法加了synchronized,若子类覆写了该方法,子类要想同步还得在子类方法上加上synchronized关键字。

synchronized与wait、nofity、nofityAll配合使用

【问题】实现一个容器,提供两个方法,add,size。写两个线程,线程1添加10个元素到容器中,线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束。
public class MyContainer {

private static List<Integer> lists = new ArrayList<>();

public static void main(String[] args) {
    final Object lock = new Object();

    //监控线程
    new Thread(()->{
        synchronized (lock) {
            System.out.println("thread 2 start...");
            if(lists.size() != 5) {
                try {
                    lock.wait();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
            System.out.println("thread 2 end.");
            lock.notify();
        }
    }, "t2").start();
    
    new Thread(()->{
        synchronized (lock) {
            for(int i = 0; i < 10; i++) {
                System.out.println("thread 1, add " + i);
                lists.add(i);

                if(lists.size() == 5) {
                    lock.notify();
                    try {
                        lock.wait();
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
                try {
                    Thread.sleep(1000);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }, "t1").start();
}

}

注:
wait()会立刻释放synchronized(obj)中的obj锁,以便其他线程可以执行obj.notify(),但是notify()不会立刻立刻释放sycronized(obj)中的obj锁,必须要等notify()所在线程执行完synchronized(obj)块中的所有代码才会释放这把锁。所以,在t1中,调用了lock对象的notify方法之后,再调用lock的wait方法释放锁,而在t2被唤醒之后,继续执行,最后还要调用lock对象的notify方法去唤醒此时处在wait状态的t1

synchronized原理

原理概述

先通过下面简单的例子看下:

public class Synchronize {

public static void main(String[] args) {
    synchronized (Synchronize.class){
        System.out.println("Synchronize");
    }
}

}

使用 javap -c Synchronize 可以查看编译之后的具体信息

从编译后的结果可以看到:在同步方法调用前加了一个 monitorenter 指令,在退出方法和异常处插入了 monitorexit 的指令

实现原理:JVM 是通过进入、退出对象监视器( Monitor )来实现对方法、同步块的同步的,具体实现是在编译之后同步代码块采用添加moniterenter、moniterexit,同步方法使用ACC_SYNCHRONIZED标记符隐式实现。每个对象都有一个monitor与之关联,运行到moniterenter时尝试获取对应monitor的所有权,获取成功就将monitor的进入数加1(所以是可重入锁,也被称为重量级锁),否则就阻塞,拥有monitor的线程运行到moniterexit时进入数减1,为0时释放monitor。其本质就是对一个对象监视器( Monitor )进行获取,而这个获取过程具有排他性从而达到了同一时刻只能一个线程访问的目的。Java内置的synchronized关键字可以认为是管程模型中的MESA模型的简化版。

Java对象如何与Monitor关联

Java对象与Monitor关联关系示意图如下:

JVM堆中存放的是对象实例,每一个对象都有对象头,对象头里有Mark Word,里面存储着对象的hashCode、GC分代年龄以及锁信息。如图所示,重量级锁中存有指向monitor的指针。
其中ObjectMonitor中几个关键字段的含义如下:
_count:记录owner线程获取锁的次数。这句话很好理解,这也决定了synchronized是可重入的。
_owner:指向拥有该对象的线程
_WaitSet:主要存放所有wait的线程的对象,也就是说如果有线程处于wait状态,将被挂入这个队列,调用了wait()方法线程会进入该队列
_EntryList:所有在等待获取锁的线程的对象,也就是说如果有线程处于等待获取锁的状态的时候,将被挂入这个队列。

详情请参考:https://www.jianshu.com/p/32e…

Monitor加锁及解锁过程

  1. 当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1。即获得对象锁;
  2. 若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet集合中等待被唤醒;
  3. 当程序里其他线程调用了notify/notifyAll方法时,就会唤醒_waitSet中的某个线程,这个线程就会再次尝试获取monitor锁。如果成功,则就会成为monitor的owner;
  4. 若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取。

详细过程请参考:https://www.hollischuang.com/…

加锁和解锁的内存语义

Java内存模型:

Java内存模型虽然有助加快执行速度,但是也带来了新的问题。不同线程之间是无法之间访问对方工作内存中的变量,线程间的变量值传递均需要通过主内存来完成,那线程的操作结果怎么让其他线程可见呢?这便需要先行发生(happens-before)原则来保证了。

先行发生规则中有如下2条:

  1. 对同一个监视器的解锁,happens-before于对该监视器的加锁
  2. 如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B

即:如果有2个线程A和B,则根据规则1,A线程释放锁 happens-before B线程获取锁,根据规则2,那A线程的操作结果对B线程是可见的。

从上图可以看出,线程A会首先先从主内存中读取共享变量a=0的值然后将该变量拷贝到自己的本地内存,进行加1操作后,再将该值刷新到主内存,整个过程即为线程A 加锁–>执行临界区代码–>释放锁相对应的内存语义。线程B获取锁的时候同样会从主内存中读取共享变量a的值,这个时候就是最新的值1,然后将该值拷贝到线程B的工作内存中去,释放锁的时候同样会重写到主内存中。
即:释放锁的时候会将值刷新到主内存中,其他线程获取锁时会强制从主内存中获取最新的值。这也验证了A happens-before B,A的执行结果对B是可见的。

详情请参考:https://www.jianshu.com/p/151…

锁的优化

高效并发是从JDK 1.5 到 JDK 1.6的一个重要改进,HotSpot虚拟机开发团队在这个版本中花费了很大的精力去对Java中的锁进行优化,如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等。这些技术都是为了在线程之间更高效的共享数据,以及解决竞争问题。但对Java开发者而言,只需要知道想在加锁的时候使用synchronized就可以了,具体的锁的优化是虚拟机根据竞争情况自行决定的
由于Java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统的帮忙,这就要从用户态转换到内核态,因此状态转换需要花费很多的处理器时间,所以优化的想法主要是能不阻塞线程就不阻塞。

  1. 适应性自旋:所谓的自旋锁就是让线程不停地执行循环体,不进行线程状态的改变。如果在锁被占用的时间很短的情况下,自旋等待的效果会非常好,反之,如果锁被占用的时间很长,自旋就会浪费CPU,所以自旋要有一定限度。在JDK1.6后,自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这便是自适应自旋了。
  2. 锁消除:通过逃逸分析,判断出代码块中不存在多个线程共享的数据,便会在编译后将锁去掉。比如:我们经常在代码中使用StringBuffer作为局部变量,而StringBuffer中的append是线程安全的,有synchronized修饰的,但是作为局部变量并不需要共享,所以这个时候便会进行锁消除的优化。
  3. 锁粗化:需要加锁的时候,我们提倡尽量减小锁的粒度,这样可以避免不必要的阻塞。但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即是没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。所以当虚拟机探测到这样的情况时,就会把加锁的范围扩大
    如以下代码:


    会被粗化成:

  4. 轻量级锁:其实就是指通过CAS操作尝试把monitor的_owner字段设置为当前线程,如果更新成功了,那么表明这个线程就拥有了该对象的锁,并将对象头的Mark Word的锁标志位转变为”00″,即代表此对象处于轻量级锁定状态。如果更新失败,则膨胀为重量级锁,等待锁的线程需要进入阻塞状态。通过ObjectMonitor类的源码可以看出:

  5. 偏向锁:意思是这个锁会偏向于第一个获得它的线程,如果在接下来执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永远不需要再进行同步。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那么偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。但这一切都是在无竞争的情况下,如果有另外一个线程尝试去获取这个锁,那偏向模式便宣告结束。

细节请参考:https://www.hollischuang.com/…

总结

本文从synchronized的用法开始,然后逐步深入介绍synchronized的实现原理,其实质是对管程模型的一种实现。虽然在用的时候就是一个关键字,但背后的内容却十分丰富,写本文的过程中,参考了许多大牛的博客,受益良多。

参考

https://blog.csdn.net/weixin_…
https://www.jianshu.com/p/32e…
https://www.jianshu.com/p/d53…
https://www.hollischuang.com/…
https://www.hollischuang.com/…
https://www.hollischuang.com/…
https://www.jianshu.com/p/e62…
https://www.jianshu.com/p/27f…
https://www.jianshu.com/p/151…

作者:bugDesigner