Synchronized详解

本文详细介绍了Java中的`synchronized`关键字,包括它的作用、使用方式(实例方法、静态方法和对象实例)以及可能遇到的问题。synchronized通过对象头的MarkWord实现锁,经历了从无锁、偏向锁、轻量级锁到重量级锁的升级过程,以应对不同竞争情况。此外,文章还讨论了锁的优化策略,如锁消除和锁粗化,并探讨了Monitor在同步机制中的角色。
摘要由CSDN通过智能技术生成

1 Synchronized的作用

synchronized是一个Java关键字,是jvm层级的,提供了一种排他机制,在同一时间点只能有一个线程执行某些操作,实现一个简单的策略来防止线程干扰内存一致性错误。

2 Synchronized的使用

[default|public|private|protected] synchronized [static] type method()

在这里插入图片描述

  • 当synchronized作用在实例方法时,相当于对当前实例加锁 ,进入同步代码前要获取到该实例的锁;
public class TrainTicketSell implements Runnable{
    private int count = 100;

    @Override
    public void run() {
        while (true){
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            sell();

        }
    }

    private synchronized void sell(){
        if (count > 0){
            System.out.println(Thread.currentThread().getName()+"售卖第"+(100-count+1)+"张票");
            count--;
        }
    }
    
     public static void main(String[] args) {
        TrainTicketSell trainTicketSell = new TrainTicketSell();
        Thread thread1 = new Thread(trainTicketSell, "窗口1");
        Thread thread2 = new Thread(trainTicketSell, "窗口2");

        thread1.start();
        thread2.start();
    }
}
  • 当synchronized作用在静态方法时,相当于给当前类加锁,进入同步代码之前要获取当前类的锁,因为Class数据存在于永久代,因此静态方法锁相当于该类的一个全局锁;
public class TrainTicketSell implements Runnable{
    private static int count = 100;

    @Override
    public void run() {
        while (true){
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            sell();

        }
    }

    private synchronized static void sell(){
        if (count > 0){
            System.out.println(Thread.currentThread().getName()+"售卖第"+(100-count+1)+"张票");
            count--;
        }
    }
    
    public static void main(String[] args) {
        TrainTicketSell trainTicketSell = new TrainTicketSell();
        TrainTicketSell trainTicketSell2 = new TrainTicketSell();

        Thread thread1 = new Thread(trainTicketSell, "窗口1");
        Thread thread2 = new Thread(trainTicketSell2, "窗口2");

        thread1.start();
        thread2.start();
    }
}
  • 当synchronized作用在某一个对象实例时,给指定对象加锁,进入同步代码之前要获取到该对象的锁;
@Override
public void run() {
    while (true){
        try {
            Thread.sleep(50);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        synchronized (this){
            if (count > 0){
                System.out.println(Thread.currentThread().getName()+"售卖第"+(100-count+1)+"张票");
                count--;
            }
        }

    }
}

public static void main(String[] args) {
        TrainTicketSell trainTicketSell = new TrainTicketSell();
        Thread thread1 = new Thread(trainTicketSell, "窗口1");
        Thread thread2 = new Thread(trainTicketSell, "窗口2");

        thread1.start();
        thread2.start();
}

img

在这里插入图片描述

使用 synchronized 时,需要注意:

  • 锁对象不能为空,因为锁信息是保存在对象头
  • 作用域不宜过大,因为作用域中的代码都是串行的,体现不出并发编程的优势,影响程序执行速度
    在能选择的情况下,既不要用Lock也不要用synchronized关键字(synchronizaied现在已经十分优化了),用java.util.concurrent包中的各种各样的类。

Synchronized锁的缺陷

第一,使用synchronized非公平锁使一些线程处于饥饿状态

synchronized实际上是非公平的,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待,这样有利于提高性能,但是也可能会导致饥饿现象。

对于某些特定的业务场景,必须要使用公平锁,这时,synchronized无法满足要求。

第二,无法知道是否成功获得锁

第三,不够灵活,不能中断一个正在试图获得锁的线程。

3 Synchronized的原理

1 对象在内存中的布局

在JVM中,对象是分成三部分存在的:对象头、实例数据、对其填充。

其中,实例数据和对其填充与synchronized无关。实例数据中存放类的属性信息,包括父类的属性信息,对齐部分不是必须部分,仅仅为了使字节对齐,满足对象起始地址必须是8的整数倍。

对象头才是实现synchronized锁的基础。对象头包含Mark WordClass Pointer。其中 Class Pointer是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
img

对象分代的角度来说,JVM的内存划分大致分为3块:分别是Permanent Generation(简称PermGen、持久代)、New Generation(又叫Young
Generation,年轻代)和Tenured Generation(又叫Old Generation,年老代)。

QQ截图20160220161316.png

分代年龄位于对象头中,用于分代 GC.记录分代年龄一共 4 bit,所以最大为 2^4 - 1 = 15。

Mark Wordsynchronized锁优化过程息息相关。

在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的32bit空间里的25位用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit为偏向锁标志位,0表示非偏向锁,还要2bit表示锁的类型,需与前面1bit结合判断。其他状态如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sHjxMWI0-1652102226645)(Synchronized锁和ThreadLocal.assets/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L1ZpcmdpbF9LMjAxNw==,size_16,color_FFFFFF,t_70.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-amgrTEDB-1652102226645)(Synchronized锁和ThreadLocal.assets/image-20220506200624817-16518387865677.png)]

2 锁的类型与优化

Synchronized同步锁,一共有四种状态:无锁偏向锁轻量级锁重量级锁,它会随着竞争情况逐渐升级。锁可以升级但是不可以降级,目的是为了提供获取锁和释放锁的效率。

锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)

无锁:锁被创建出来,在线程获得该对象锁之前,对象处于无锁状态

1)偏向锁

引入背景:在大多实际环境下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获取,那么在同一个线程反复获取所释放锁中,其中并还没有锁的竞争,那么这样看上去,多次的获取锁和释放锁带来了很多不必要的性能开销和上下文切换。

为了解决这一问题引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS(比较替换)操作来加锁和解锁。只需要简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获取到了锁。

2)偏向锁的撤销

偏向锁使用了一种等待竞争出现才会释放锁的机制。所以当其他线程尝试获取偏向锁时,持有偏向锁的线程才会释放锁。但是偏向锁的撤销需要等到全局安全点(就是当前线程没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,让你后检查持有偏向锁的线程是否活着。如果线程不处于活动状态,直接将对象头设置为无锁状态。如果线程活着,JVM会遍历栈帧中的锁记录,栈帧中的锁记录和对象头要么偏向于其他线程,要么恢复到无锁状态或者标记对象不适合作为偏向锁。

3) 轻量级锁

  • 自旋锁

引入背景:在多线程竞争锁时,当一个线程获取锁时,它会阻塞所有正在竞争的线程,这样对性能带来了极大的影响。在挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作对系统的并发性能带来了很大的压力。同时共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复阻塞线程并不值得

在如今多处理器环境下,完全可以让另一个没有获取到锁的线程在门外等待一会(自旋),但不放弃CPU的执行时间。等待持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这便是自旋锁由来的原因。

自旋锁早在JDK1.4 中就引入了,只是当时默认时关闭的。在JDK 1.6后默认为开启状态。自旋锁本质上与阻塞并不相同,先不考虑其对多处理器的要求,如果锁占用的时间非常的短,那么自旋锁的性能会非常的好。

在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉CPU资源。因此自旋等待的时间必须要有一定的限度,如果自选超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在JDK定义中,自旋锁默认的自旋次数为10,也可以使用参数-XX:PreBlockSpin来更改。

新的问题:如果线程锁在线程自旋刚结束就释放掉了锁,那就有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋,来提高并发的性能。

  • 自适应自旋锁

在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准确,JVM也会越来越聪明。

  • 轻量级锁

在JDK 1.6之后引入的轻量级锁(轻量级锁对使用者是透明的),需要注意的是轻量级锁并不是替代重量级锁的,而是对在大多数情况下同步块竞争并不激烈时提出的一种优化。它可以减少重量级锁对线程的阻塞带来的线程开销,从而提高并发性能。

如果要理解轻量级锁,那么必须先要了解HotSpot虚拟机中对象头的内存布局。上面介绍Java对象头也详细介绍过。在对象头中(Object Header)存在两部分。第一部分用于存储对象自身的运行时数据,HashCodeGC Age锁标记位是否为偏向锁。等。一般为32位或者64位(视操作系统位数定)。官方称之为Mark Word,它是实现轻量级锁偏向锁的关键。 另外一部分存储的是指向方法区对象类型数据的指(Klass Point),如果对象是数组的话,还会有一个额外的部分用于存储数据的长度。

  • 加锁过程:

在线程执行同步块之前,JVM会先在当前线程的栈帧中创建一个锁记录Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝 。
在这里插入图片描述
然后,虚拟机使用CAS 操作将标记字段Mark Word拷贝到锁记录中,并且将Mark Word更新为指向Lock Record的指针。如果更新成功了,那么这个线程就有用了该对象的锁,并且对象Mark Word的锁标志位更新为(Mark Word中最后的2bit)00,即表示此对象处于轻量级锁定状态,如图:在这里插入图片描述
如果这个更新操作失败,JVM会检查当前的Mark Word中是否存在指向当前线程的栈帧的指针,如果有,说明该锁已经被获取,可以直接调用。如果没有,则说明该锁被其他线程抢占了。

如果有超过1/2核数的线程竞争同一个锁,那轻量级锁就不再有效,直接膨胀位重量级锁,没有获得锁的线程会被阻塞,进入该锁的等待队列(不消耗CPU资源)。此时,锁的标志位为10,Mark Word中存储的时指向重量级锁的指针。

4)锁消除

锁消除是指虚拟机即时编译器再运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持。意思就是:JVM会判断再一段程序中的同步明显不会逃逸出去从而被其他线程访问到,那JVM就把它们当作栈上数据对待,认为这些数据时线程独有的,不需要加同步。此时就会进行锁消除。

5)锁粗化

原则上在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。

大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁的进行互斥同步操作也会导致不必要的性能操作。

6)锁的优缺点对比

优点缺点场景
偏向锁加锁和解锁不需要CAS操作,没有额外的性能消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗适用于只有一个线程访问同步块的场景
轻量级锁竞争的线程不会阻塞,提高了响应速度如线程成始终得不到锁竞争的线程,使用自旋会消耗CPU性能追求响应时间,同步块执行速度非常快
重量级锁线程竞争不适用自旋,不会消耗CPU线程阻塞,响应时间缓慢,在多线程下,频繁的获取释放锁,会带来巨大的性能消耗追求吞吐量,同步块执行速度较长
3 Monitor

Monitor是由ObjectMonitor实现的,他是实现底层同步的关键。如果使用synchronized给对象上锁(重量级)之后,该对象头的Mark Work中就被设置指向Monitor对象的指针

monitor描述为对象监视器,可以类比为一个特殊的房间,这个房间中有一些被保护的数据,monitor保证每次只能有一个线程能进入这个房间进行访问被保护的数据,进入房间即为持有monitor,退出房间即为释放monitor将 lock对象 MarkWord 重置, 唤醒 EntryList。
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
关注红色方框里的monitorentermonitorexit即可。

MonitorenterMonitorexit指令,会让对象在执行,使其锁计数器加1或者减1。每个对象都与一个monitor 相关联而一个monitor在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:

  • monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待
  • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加
  • 这把锁已经被别的线程获取了,等待锁释放

monitorexit指令:释放对于monitor的所有权,释放过程很简单,就是将monitor的计数器减1,如果减完以后,计数器不是0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该monitor的所有权,即释放锁。

下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:
在这里插入图片描述
该图可以看出,任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

方法级别的同步是隐式的,作为方法调用的一部分。同步方法的常量池中会有一个ACC_SYNCHRONIZED标志。
当调用一个设置了ACC_SYNCHRONIZED标志的方法,执行线程需要先获得monitor锁,然后开始执行方法,方法执行之后再释放monitor锁,当方法不管是正常return还是抛出异常都会释放对应的monitor锁。
在这期间,如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住。如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放。

在这里插入图片描述
总结

  • 同步代码块是通过monitorentermonitorexit来实现,当线程执行到monitorenter的时候要先获得monitor锁,才能执行后面的方法。当线程执行到monitorexit的时候则要释放锁。
  • 同步方法是通过中设置ACC_SYNCHRONIZED标志来实现,当线程执行有ACC_SYNCHRONIZED标志的方法,需要获得monitor锁。
  • 每个对象维护一个加锁计数器,为0表示可以被其他线程获得锁,不为0时,只有当前锁的线程才能再次获得锁。
  • 同步方法和同步代码块底层都是通过monitor来实现同步的。
  • 每个对象都与一个monitor相关联,线程可以占有或者释放monitor。

monitor监视器源码是C++写的,在虚拟机的ObjectMonitor.hpp文件中。

ObjectMonitor() {
 _header       = NULL;
 _count        = 0;	//记录owner线程获取锁的次数
 _waiters      = 0,
 _recursions   = 0;  // 线程重入次数
 _object       = NULL;  // 存储Monitor对象
 _owner        = NULL;  // 指向持有ObjectMonitor对象的线程
 _WaitSet      = NULL;  // wait状态的线程列表,处于wait状态的线程会被加入到_WaitSet
 _WaitSetLock  = 0 ;
 _Responsible  = NULL ;
 _succ         = NULL ;
 _cxq          = NULL ;  // 单向列表
 FreeNext      = NULL ;
 _EntryList    = NULL ;  // 处于等待锁状态block状态的线程列表
 _SpinFreq     = 0 ;
 _SpinClock    = 0 ;
 OwnerIsThread = 0 ;
 _previous_owner_tid = 0;
}

在这里插入图片描述

  • 想要获取monitor的线程,首先会进入EntryList队列。
  • 当某个线程获取到对象的monitor后,进入Owner区域,设置为当前线程,同时计数器count加1。
  • 如果线程调用了wait()方法,则会进入WaitSet队列。它会释放monitor锁,即将owner赋值为null,count自减1,进入WaitSet队列阻塞等待。
  • 如果其他线程调用 notify() / notifyAll(),会唤醒WaitSet中的某个线程,该线程再次尝试获取monitor锁,成功即进入Owner区域。
  • 同步方法执行完毕了,线程退出临界区,会将monitorowner设为null,并释放监视锁。

对象跟Monitor关联(JVM的线程跟操作系统的线程是同一个东西)
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值