Synchronized与Java线程的关系

这篇具有很好参考价值的文章主要介绍了Synchronized与Java线程的关系。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

前言

​ Java多线程处理任务时,为了线程安全,通常会对共享资源进行加锁,拿到锁的线程才能进行访问共享资源。而加锁方式通过都是Synchronized锁或者Lock锁。

​ 那么多线程在协同工作的时候,线程状态的变化都与锁对象有关系。

Synchronized锁

​ Java采用synchronized关键字、以互斥同步的方式的解决线程安全问题。一般Synchronized主要用于同步代码块、实例方法、静态方法。

一、使用方式 字节码分析

// 使用
public synchronized void test1(){
}
public void test2(){
    synchronized(new Test()){
    }
}
public static synchronized void test3(){
}

//反编译
public synchronized void test1();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED    // here

  public void test2();
    descriptor: ()
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #2                  // class com/easy/helloworld/Test
         3: dup
         4: invokespecial #3                  // Method "<init>":()V
         7: dup
         8: astore_1
         9: monitorenter                   // here
        10: aload_1
        11: monitorexit                    // here
        12: goto          20
        15: astore_2
        16: aload_1
        17: monitorexit                    // here
        18: aload_2
        19: athrow
        20: return

  public static synchronized void test3();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED   // here

​ 由上可知,同步代码:通过moniterenter、moniterexit 关联到到一个monitor对象,进入时设置Owner为当前线程,计数+1、退出-1。除了正常出口的 monitorexit,还在异常处理代码里插入了 monitorexit。

​ 而实例方法、静态方式是隐式调用moniterenter、moniterexit。

二、Moniterenter、Moniterexit

​ monitorenter和monitorexit这两个jvm指令,主要是基于 Mark WordObject monitor来实现的。

​ 在 JVM 中,对象在内存中分为三块区域:

  • 对象头:由Mark WordKlass Point构成。

    • Mark Word(标记字段):用于存储对象自身的运行时数据,例如存储对象的HashCode,分代年龄、锁标志位等信息,是synchronized实现轻量级锁和偏向锁的关键。 64位JVM的Mark Word组成如下:

      Synchronized与Java线程的关系,多线程与线程并发,java

    • Klass Point(类型指针):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

  • 实例数据:这部分主要是存放类的数据信息,父类的信息。

  • 字节对齐:为了内存的IO性能,JVM要求对象起始地址必须是8字节的整数倍。对于不对齐的对象,需要填充数据进行对齐。

​ 在JDK 1.6之前,synchronized只有传统的锁机制,直接关联到monitor对象,存在性能上的瓶颈。在JDK 1.6后,为了提高锁的获取与释放效率,JVM引入了两种锁机制:偏向锁和轻量级锁。它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。这几种锁的实现和转换正是依靠对象头中的Mark Word

Synchronized锁机制

1、偏向锁

1)引入偏向锁的初衷

​ 在没有多线程竞争的情况下,尽量减少不必要的轻量级锁的执行。轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只依赖一次CAS原子指令。

​ 但在多线程竞争时,需要进行偏向锁撤销步骤,因此其撤销的开销必须小于节省下来的CAS开销,否则偏向锁并不能带来收益。JDK 1.6中默认开启偏向锁,可以通过-XX:-UseBiasedLocking来禁用偏向锁。在JDK15中,已经默认禁用偏向锁了。

2)关键字

prototype_header:JVM中的每个类有一个类似mark wordprototype_header用来标记该class的epoch和偏向开关等信息

匿名偏向状态:锁对象mark word标志位为101,且存储的Thread ID为空时的状态(即锁对象为偏向锁,且没有线程偏向于这个锁对象)。

Atomic::cmpxchg_ptrCAS函数。这个方法有三个参数,依次为exchange_valuedestcompare_value。如果dest的值为compare_value则更新为exchange_value,并返回compare_value。否则,不更新并返回实际原值

3)偏向锁流程

步骤 1、从当前线程的栈中找到一个空闲的Lock Record,并指向当前锁对象。

步骤 2、获取对象的markOop数据mark,即对象头的Mark Word;

步骤 3、判断锁对象的mark word是否是偏向模式,即低3位是否为101。若不是,进入步骤4。若是,计算anticipated_bias_locking_value,判断偏向状态:

步骤 3.1anticipated_bias_locking_value若为0,代表**偏向的线程是当前线程,且mark word的epoch等于class的epoch,**这种情况下直接执行同步代码块,什么都不用做。

步骤 3.2判断class的prototype_header是否为非偏向模式。若为非偏向模式,CAS尝试将对象恢复为无锁状态。无论cas是否成功都会进入轻量级锁逻辑。

步骤 3.3、**如果epoch偏向时间戳已过期,则需要重偏向。**利用CAS指令将锁对象的mark word替换为一个偏向当前线程且epoch为类的epoch的新的mark word

步骤 3.4CAS将偏向线程改为当前线程后,如果当前是匿名偏向(即对象头中的bit field存储的Thread ID为空)且无并发冲突,则能修改成功获取偏向锁,否则进入锁升级的逻辑。

步骤 4、走到一步会进行轻量级锁逻辑。构造一个无锁状态的mark word,然后存储到Lock Record

设置为无锁状态的原因是:轻量级锁解锁时是将对象头的mark wordcas替换为Lock Record中的Displaced Mark Word,所以设置为无锁状态。如果是锁重入,则将Lock RecordDisplaced Mark Word设置为null,放到栈帧中,起到计数作用。

注意

只有匿名偏向的对象才能进入偏向锁模式。JVM启动时会延时初始化偏向锁,默认是4000ms。初始化后会将所有加载的Klass的prototype header修改为匿名偏向样式。当创建一个对象时,会通过Klass的prototype_header来初始化该对象的对象头。

​ 简单的说,偏向锁初始化结束后,后续所有对象的对象头都为匿名偏向样式,在此之前创建的对象则为无锁状态。而对于无锁状态的锁对象,如果有竞争,会直接进入到轻量级锁。这也是为什么JVM启动前4秒对象会直接进入到轻量级锁的原因。

​ 那么为什么要延迟初始化?JVM启动时必不可免会有大量sync的操作,而偏向锁并不是都有利。如果开启了偏向锁,会发生大量锁撤销和锁升级操作,大大降低JVM启动效率。

4)偏向锁的撤销

偏向锁的 撤销(revoke)是一个很特殊的操作,为了执行撤销操作,需要等待全局安全点,此时所有的工作线程都停止了执行。

偏向锁的撤销操作并不是将对象恢复到无锁可偏向的状态,而是在偏向锁的获取过程中,发现可能存在竞争时,直接将一个被偏向的对象升级到被加了轻量级锁的状态。

场景说明:

​ 锁已经偏向线程A,此时线程B尝试获取锁。这种情况下会走到Mark标记的分支。

​ 如果需要撤销的是当前线程,只要遍历当前线程的栈就能拿到lock record,可以直接调用revoke_bias,不需要等到safe point再撤销。**在调用Object#hashcode时,也会走到该分支将为偏向锁的锁对象直接恢复为无锁状态。**若不是当前线程,会被push到VM Thread中等到safe point的时候再执行。

​ VMThread内部维护了一个VMOperationQueue类型的队列,用于保存内部提交的VM线程操作VM_operation。GC、偏向锁的撤销等操作都是在这里被执行。

撤销流程:

**步骤 1、查看偏向的线程是否存活,如果已经死亡,则直接撤销偏向锁。**JVM维护了一个集合存放所有存活的线程,通过遍历该集合判断某个线程是否存活。

步骤 2、偏向的线程是否还在同步块中,如果不在,则撤销偏向锁。如果在同步块中,执行步骤3。

​ 而是否在同步块的判断依据偏向锁的重入计数方式:在偏向锁的获取中,每次进入同步块的时候都会在栈中找到第一个可用(即栈中最高的)的Lock Record,将其obj字段指向锁对象。每次解锁的时候都会把最低的Lock Record移除掉,所以可以通过遍历线程栈中的Lock Record来判断是否还在同步块中。轻量级锁的重入也是基于Lock Record的计数来判断。

步骤 3、升级为轻量级锁。将偏向线程所有相关Lock RecordDisplaced Mark Word设置为null,再将最高位的Lock RecordDisplaced Mark Word 设置为无锁状态,然后将对象头指向最高位的Lock Record。这里没有用到CAS指令,因为是在safepoint,可以直接升级成轻量级锁。

小结

​ 当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。

​ 因此,JVM中增加了一种批量重偏向/撤销的机制以减少锁撤销的开销,而mark word中的epoch也是在这里被大量应用。但无论怎么优化,偏向锁的撤销仍有一定不可避免的成本。如果业务场景存在大量多线程竞争,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。

总结

如果当前锁已偏向其他线程||epoch值过期||class偏向模式关闭||获取偏向锁的过程中存在并发冲突,都会进入到锁膨胀InterpreterRuntime::monitorenter方法, 在该方法中会进行偏向锁撤销和升级。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2dzNki4f-1692344710003)(https://tech.youzan.com/content/images/2021/07/—.svg)]

2)轻量级锁

目的:

​ 在多线程交替执行同步块的情况下,尽量避免重量级锁使用的操作系统互斥量带来的开销。但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。

​ 在偏向锁逻辑中,cas失败会执行到InterpreterRuntime::monitorenter。在轻量级锁逻辑中,如果当前线程不是轻量级锁的重入,也会执行到InterpreterRuntime::monitorenter

​ 其内部函数fast_enter的流程,主要逻辑为revoke_and_rebias:如果当前是偏向模式且偏向的线程还在使用锁,会将锁的mark word改为轻量级锁的状态,并将偏向的线程栈中的Lock Record修改为轻量级锁对应的形式(此时Lock Record是无锁状态),且返回值不是BIAS_REVOKED_AND_REBIASED,会继续执行slow_enter

slow_enter流程步骤

步骤 1markOop mark = obj->mark()方法获取对象的markOop数据mark;

步骤 2mark->is_neutral()方法判断mark是否为无锁状态,标识位001

步骤 3、如果mark处于无锁状态,把mark保存到BasicLock对象(Lock Record的属性)的displaced_header字段;

步骤 3.1、通过CAS尝试将Mark Word更新为指向BasicLock对象的指针,如果更新成功,表示竞争到锁,则执行同步代码,否则执行步骤4;

步骤 4、如果是重入,则设置Displaced Mark Word为null。

步骤 5、到这说明有多个线程竞争轻量级锁,轻量级锁需要膨胀升级为重量级锁;

轻量级锁的释放

​ 轻量级锁释放时需要将Displaced Mark Word替换回对象头的mark word中。如果CAS失败或者是重量级锁则进入到InterpreterRuntime::monitorexit方法中。monitorexit直接调用slow_exit方法释放Lock Record

最后执行的是如果是fast_exit方法,且是轻量级锁,尝试cas替换mark word。若解锁时有竞争,会调用inflate方法进行重量级锁膨胀,升级到到重量级锁后再执行exit方法。

3)重量级锁

​ 重量级锁通过对象内部的监视器(monitor)实现,其依赖于底层操作系统的Mutex Lock实现,需要额外的用户态到内核态切换的开销。由上文分析,slow_enter获取轻量级锁未成功时,会在inflate中完成锁膨胀。

​ 而inflate其中是一个for循环,主要是为了处理多线程同时调用inflate的情况。然后会根据锁对象的状态进行不同的处理:

1.已经是重量级状态,说明膨胀已经完成,返回并继续执行ObjectMonitor::enter方法。
2.如果是轻量级锁则需要进行膨胀操作。
3.如果是膨胀中状态,则进行忙等待。
4.如果是无锁状态则需要进行膨胀操作。

步骤过程

步骤 1、调用omAlloc获取一个可用的ObjectMonitor对象。在omAlloc方法中会先从线程私有monitor集合omFreeList中分配对象,如果omFreeList中已经没有monitor对象,则从JVM全局gFreeList中分配一批monitoromFreeList中;

步骤 2、通过CAS尝试将Mark Word设置为markOopDesc:INFLATING,标识当前锁正在膨胀中。如果CAS失败,说明同一时刻其它线程已经将Mark Word设置为markOopDesc:INFLATING,当前线程进行自旋等待膨胀完成。

步骤 3、如果CAS成功,设置monitor的各个字段:设置monitor的header字段为displaced mark word,owner字段为Lock Record,obj字段为锁对象等;

步骤 4、设置锁对象头的mark word为重量级锁状态,指向第一步分配的monitor对象;

monitor竞争

​ 当锁膨胀inflate执行完并返回对应的ObjectMonitor时,并不表示该线程竞争到了锁,真正的锁竞争发生在ObjectMonitor::enter方法中。

monitor等待

ObjectMonitor竞争失败的线程,通过自旋执行ObjectMonitor::EnterI方法等待锁的释放。

EnterI大致原理

一个ObjectMonitor对象包括两个同步队列(_cxq_EntryList) ,以及一个等待队列_WaitSet。cxq、EntryList 、WaitSet都是由ObjectWaiter构成的链表结构。其中,_cxq为单向链表,_EntryList为双向链表。

​ 当一个线程尝试获得重量级锁且没有竞争到时,该线程会被封装成一个ObjectWaiter对象插入到cxq的队列的队首,然后调用park函数挂起当前线程,进入BLOCKED状态。

​ 当线程释放锁时,会根据唤醒策略,从cxq或EntryList中挑选一个线程unpark唤醒。如果线程获得锁后调用Object#wait方法,则会将线程加入到WaitSet中,进入WAITING或TIMED_WAITING状态。

​ 当被Object#notify唤醒后,会将线程从WaitSet移动到cxq或EntryList中去,进入BLOCKED状态。需要注意的是,当调用一个锁对象的waitnotify方法时,若当前锁的状态是偏向锁或轻量级锁则会先膨胀成重量级锁。

monitor释放

​ 当某个持有锁的线程执行完同步代码块时,会进行锁的释放。在HotSpot中,通过退出monitor的方式实现锁的释放,并通知被阻塞的线程,具体实现位于ObjectMonitor::exit方法中。

Java线程

1、线程的实现

1)内核线程实现

内核线程(Kernel-Level Thread,KLT):由内核来完成线程切换,内核通过调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。 程序一般不会直接去使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),也就是通常意义上的线程。

优点:每个LWP都是独立的调度单元。一个LWP被阻塞,不影响其他LWP。

缺点:基于KLT,耗资源。线程的创建、析构、同步都需要进行系统调用,频繁的用户态、内核态切换。

2)用户线程实现(User Thread,UT)

广义:非内核线程,都可认为是用户线程。(包括LWT,虽然LWT的大多操作都要映射到KLT)

狭义:完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。UT也只感知到掌管这些UT的进程P。

优点:用户线程的创建、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。

缺点:线程的创建、销毁、切换和调度都是用户必须考虑到问题。“阻塞如何处理”、“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难。

3)混合模式。即存在用户线程,也存在轻量级进程

​ 用户线程的创建、切换、析构等操作依然廉价,可以支持大规模的用户线程并发,且可以使用内核线程提供的线程调度功能及处理器映射。

​ 线程的实现依赖操作系统支持的线程模型。在主流的操作系统上,hotspot、classic、art等虚拟机默认是 1:1的线程模型。在Solaris平台上,hotspot支持1:1、N:M两种线程模型。

2、线程的转换

​ 线程的状态,指的是Thread 类中threadStatus的值。

​ 创建Thread 类的对象,才能谈其状态。这个时候,线程t就处于新建状态。但他还不是“线程”。**调用start()后,会执行一个native方法创建内核线程。 这时候才有一个真正的线程创建出来,并即刻开始运行。这个内核线程与线程t进行1:1的映射。**这时候t具备运行能力,进入RUNNABLE状态。 RUNNABLE可以细分为READY和RUNNING,两者的区别只是是否等待到了资源并开始运行。

​ 处于RUNNABLE且未运行的线程,会进入一个就绪队列中,等待操作系统的调度。处于就绪队列的线程都在等待资源,这个资源可以是cpu的时间片、也可以是系统的IO。

3、线程相关方法

1)wait

​ 通过object获得objectMonitor,将Thread封装成OjectWaiter对象,然后addWaiter将它插入waitSet中,进入waiting或timed_waiting状态。最后释放锁,并通过底层的park方法挂起线程;

2)notify

​ 通过object获得objectMonitor,调用objectMonitor的notify方法。这个notify最后会走到ObjectMonitor::DequeueWaiter方法,获取waitSet列表中的第一个ObjectWaiter节点。并根据不同的策略,将取出来的ObjectWaiter节点,加入到EntryListcxq中。 notifyAll的实现类似于notify,主要差别在多了个for循环。

notifynotifyAll并不会立即释放所占有的ObjectMonitor对象,其真正释放ObjectMonitor的时间点是在执行monitorexit指令。

一旦释放ObjectMonitor对象了,entryListcxq中的ObjectWaiter节点会依据QMode所配置的策略,通过ExitEpilog方法唤醒取出来的ObjectWaiter节点。被唤醒的线程,继续参与monitor的竞争。若竞争失败,重新进入BLOCKED状态

3)join

join的本质仍然是 wait() 方法。在使用join时,JVM会帮我们隐式调用notify,因此我们不需要主动notify唤醒主线程。 而sleep()方法最终是调用SleepEvent对象的park方法

4)sleep

Thread.sleep在jvm层面上是调用thread中SleepEvent对象的park()方法实现阻塞线程,在此过程中会通过判断时间戳来决定线程的睡眠时间是否达到了指定的毫秒。

5)park

parkunpark方法也与同步语义无关。每个线程都与一个许可(permit)关联。unpark函数为线程提供permit,线程调用park函数则等待并消耗permit。

小结

​ 1、 JVM线程状态不代表内核线程状态。

​ 2、BLOCKED的线程一定处于entryList或cxq中,而处于WAITING和TIMED WAITING的线程,可能是由于执行了sleep或park进入该状态,不一定在waitSet中。也就是说,处于BLOCKED状态的线程一定是与同步相关。由这可延伸出,调用 jdk 的 lock并获取不到锁的线程,进入的是 WAITING 或 TIMED_WAITING 状态,而不是BLOCKED状态。

总结

Synchronized与Java线程的关系,多线程与线程并发,java文章来源地址https://www.toymoban.com/news/detail-666453.html

到了这里,关于Synchronized与Java线程的关系的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • java并发:synchronized锁详解

            在java多线程当中,我们总有遇到过多个线程操作一个共享数据时,而这个最后的代码执行结果并没有按照我们的预期一样得到正确的结果。此时我们就需要让代码执行在操作共享变量时,要等一个线程操作完毕时,另一个线程才能去操作这个共享变量。synchroniz

    2024年02月12日
    浏览(39)
  • 【Java 并发编程】一文详解 Java 内置锁 synchronized

    存在共享数据; 多线程共同操作共享数。 synchronized 可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块,同时 synchronized 可以保证一个线程的变化可见(可见性),即可以代替 volatile。 多线程编程中,有可能会出现多个线程同时访问同一个共享、可变

    2024年02月02日
    浏览(35)
  • 【并发编程】深入理解Java并发之synchronized实现原理

    分析: 通过 new MyThread() 创建了一个对象 myThread ,这时候堆中就存在了共享资源 myThread ,然后对 myThread 对象创建两个线程,那么thread1线程和thread2线程就会共享 myThread 。 thread1.start() 和 thead2.start() 开启了两个线程,CPU会随机调度这两个线程。假如 thread1 先获得 synchronized 锁,

    2024年02月04日
    浏览(60)
  • java线程-synchronized详解

    解决线程原子性问题,最常见的手段就是加锁,Java提供了两种加锁的方式,一个synchronized隐式锁,另外一个是通过J.U.C框架提供的Lock显式加锁。本文主要介绍一个Synchronized的实现方式。 synchronized解决的是多个线程之间访问资源的同步性,synchronized 翻译为中文的意思是

    2024年02月10日
    浏览(47)
  • Java并发之synchronized关键字和Lock接口

    欢迎点赞阅读,一同学习交流,有疑问请留言 。 GitHub上也有开源 JavaHouse,欢迎star 当开发过程中,我们遇到并发问题。怎么解决? 一种解决方式,简单粗暴:上锁。将千军万马都给拦下来,只允许一个人过独木桥。书面意思就是将并行的程序变成串行的程序。现实的锁有门锁

    2024年02月08日
    浏览(40)
  • Java多线程(二)——synchronized 详解

    目录 1 volatile 1.1保证变量可见性 1.2 不能保证数据的原子性举例 1.3 禁止JVM指令重排序 2 synchronized 2.1 概念及演进 2.2 对象锁和类锁 2.3 synchronized 的用法分类 2.4 synchronized 的用法详解 2.5 synchronized总结+补充 3 synchronized 底层原理 3.1 synchronized 同步语句块的情况

    2024年02月13日
    浏览(44)
  • Java关键字之synchronized详解【Java多线程必备】

    点击   Mr.绵羊的知识星球  解锁更多优质文章。 目录 一、介绍 二、特性 1. 线程安全 2. 互斥访问 3. 可重入性 4. 内置锁 三、实现原理 四、和其他锁比较 1. 优点 2. 缺点 五、注意事项和最佳实践 六、使用案例 1. 案例一 2. 案例二     synchronized是Java中最基本的同步机制之一,

    2024年01月24日
    浏览(48)
  • Java多线程篇(1)——深入分析synchronized

    synchronized实现原理的由浅入深依次为 字节码层面:monitorenter/monitorexit 指令 java对象层面: Mark Word 对象头 JVM层面: CAS、自旋 、 ObjectMonitor(MESA管层模型:cxq,entryList,wait三个队列) 操作系统层面: mutex 锁 其中 mark word 对象头如下图: 说到锁升级,我相信很多人都错

    2024年02月09日
    浏览(36)
  • Java多线程(4)---死锁和Synchronized加锁流程

    目录 前言 一.synchronized 1.1概念  1.2Synchronized是什么锁? 1.3Synchronized加锁工作过程 1.4其他优化操作 二.死锁 2.1什么是死锁 2.2死锁的几个经典场景 2.3死锁产生的条件 2.4如何解决死锁 🎁个人主页:tq02的博客_CSDN博客-C语言,Java,Java数据结构领域博主 🎥 本文由 tq02 原创,首发于

    2024年02月13日
    浏览(40)
  • 【创作赢红包】Java多线程:synchronized锁方法块

    synchronized同步代码块 用synchronized声明方法在某些情况下是有弊端的,比如A线程调用同步方法执行一个较长时间的任务,那么B线程必须等待比较长的时间。这种情况下可以尝试使用synchronized同步语句块来解决问题。看一下例子:     运行结果,分两部分来看: synchr

    2023年04月09日
    浏览(38)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包