synchronized优化

序言

在JDK1.5中,synchronized是性能低效的。因为这是一个重量级操作,它对性能大的影响是阻塞的是实现,挂起 线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性带来了很大的压力。相比之下使用Java 提供的Lock对象,性能更高一些。

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class MyThread extends Thread{
    private Lock lock = new ReentrantLock();
    private int ticket = 100;

    @Override
    public void run() {
        for(int i = 0;i < 100;i++){
            lock.lock();
            if(this.ticket > 0){
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName()+"还剩下"+this.ticket--+"票");
            }
            lock.unlock();
        }
    }
}

public class TestThread {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        new Thread(myThread,"黄牛1").start();
        new Thread(myThread,"黄牛2").start();
        new Thread(myThread,"黄牛3").start();
    }
}

到了JDK1.6,发生了变化,对synchronize加入了很多优化措施,有自适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在JDK1.6上synchronize的性能并不比Lock差。官方也表示,他们也更支持synchronized,在未来的版本中还有优化余地,所以还是提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。

上一篇文章对Synchronized用法有一定的介绍,它大的特征就是在同一时刻只有一个线程能够获得对象的监视器 (monitor),从而进入到同步代码块或者同步方法之中,即表现为互斥性(排它性)。这种方式肯定效率低下,每次只能通过一个线程,既然每次只能通过一个,这种形式不能改变的话,那么我们能不能让每次通过的速度变快一 点呢?

打个比方,去收银台付款,之前的方式是,大家都去排队,然后取纸币付款收银员找零,有的时候付款的时候在包里拿出钱包再去拿出钱,这个过程是比较耗时的,然后,支付宝解放了大家去钱包找钱的过程,现在只需要扫描下就可以完成付款了,也省去了收银员跟你找零的时间的了。同样是需要排队,但整个付款的时间大大缩短,是不是整体的效率变高速率变快了?这种优化方式同样可以引申到锁优化上,缩短获取锁的时间。

在聊到锁的优化也就是锁的几种状态前,有两个知识点需要先关注:(1)CAS操作 (2)Java对象头

CAS(Compare and Swap)操作

什么是CAS?

使用锁时,线程获取锁是一种悲观锁(JDK1.6之前内建锁)策略。即假设每一次执行临界区代码(访问共享资源)都会产生冲突,所以当线程获取到锁的同时也会阻塞其他未获取到锁的线程。

CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源时不会出现冲突,由于不会出现冲突自然不会阻塞其他线程。因此线程就不会出现阻塞停顿的状态。出现冲突时,无锁操作使用CAS(比较交换)来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。

CAS的操作过程

CAS比较交换的过程可以通俗的理解为CAS(V,O,N),包含三个值分别为:V 内存地址存放的实际值;O 预期的值 (旧值);N 更新的新值。

V = O时,也就是说期望值和内存中实际的值相同,表明该值没有被其他线程更改过,即该旧值O就是目前来说新的值了,自然而然可以将新值N赋值给V。反之,V != O时,表明该值已经被其他线程改过了,则该旧值O不是新版本的值了,所以不能将新值N赋给V,返回V即可。

当多个线程使用CAS操作一个变量时,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试(自旋)或挂起线程(阻塞)。

CAS的实现需要硬件指令集的支撑,在JDK1.5后虚拟机才可以使用处理器提供的CMPXCHG指令实现。

元老级的Synchronized(未优化前)主要的问题是:在存在线程竞争的情况下会出现线程阻塞和唤醒锁带来的性能问题,因为这是一种互斥同步(阻塞同步)。而CAS并不是武断的将线程挂起,当CAS操作失败后会进行一定的尝试,而非进行耗时的挂起唤醒的操作,因此也叫做非阻塞同步。这是两者主要的区别。

CAS的问题

  • ABA问题

因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。

解决方案
①可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决,即 1A->2B->3A
②或者在JDK1.5后使用atomic包的 AtomicStampedReference类解决问题。

  • 自旋会浪费大量的CPU资源问题

与线程阻塞相比,自旋会浪费大量的处理器资源。因为当前线程仍处于运行状态,只不过跑的是无用指令。它期望在运行无用指令的过程中,锁能够被释放出来。

我们可以用等红绿灯作为例子。Java 线程的阻塞相当于熄火停车,而自旋状态相当于怠速停车。如果红灯的等待时间非常长,那么熄火停车相对省油一些;如果红灯的等待时间非常短,比如我们在同步代码块中只做了一个整型加法,那么在短时间内锁肯定会被释放出来,因此怠速停车更合适。
然而,对于JVM来说,它并不能看到红灯的剩余时间,也就没法根据等待时间的长短来选择是自旋还是阻塞。

解决方案
自适应自旋(重量级锁的优化):根据以往自旋等待时能否获取锁,来动态调整自旋的时间(循环次数)。如果在自旋时获取到锁,则会稍微增加下一次自旋的时长;否则就稍微减少下一次自旋时长。

  • 公平性问题

自旋状态还带来另外一个副作用,不公平的锁机制。处于阻塞状态的线程,无法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁。

内建锁无法实现公平机制,而lock体系可以实现公平锁

对象头

在同步的时候是获取对象的monitor,即获取到对象的锁。那么对象的锁怎么理解?无非就是类似对对象的一个标志,那么这个标志就是存放在Java对象的对象头Java对象头里的Mark Word里默认存放对象的Hashcode,分代年龄和锁标记位。32位JVM Mark Word默认存储结构为:

如上图在Mark Word会默认存放hasdcode,年龄值以及锁标志位等信息。 Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。对象的MarkWord变化为下图:

Epoch字段值:表示此对象偏向锁的撤销次数。默认撤销40次以上,表示此对象不再适用于偏向锁,当下次线程再次获取此对象时,直接变为轻量级锁。
只有一次CAS过程,出现在第一次加锁时。

偏向锁

HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

偏向锁是四种状态中最乐观的一种锁:从始至终只有一个线程请求某一把锁。

这就好比你在私家庄园里装了个红绿灯,并且庄园里只有你在开车。偏向锁的做法便是在红绿灯处识别来车的车牌号。如果匹配到你的车牌号,那么直接亮绿灯。

偏向锁的获取

  • 当一个线程访问同步块并成功获取到锁时,会在对象头和栈帧中的锁记录字段里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,直接进入。

只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

  • 当线程访问同步块失败时,使用CAS竞争锁,并将偏向锁升级为轻量级锁。

偏性锁的撤销(开销较大):

偏向锁使用了一种等待竞争出现才释放锁的机制,所以当其他线程竞争偏向锁时,持有偏向锁的线程才会释放偏向锁,并将锁膨胀为轻量级锁(持有偏向锁的线程依然存活的时候)。如果持有线程已经终止,则将锁对象的对象头设置为无锁状态。
在这里插入图片描述

如图,偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,后唤醒暂停的线程。

下图线程1展示了偏向锁获取的过程,线程2展示了偏向锁撤销的过程。

如何关闭偏向锁

偏向锁在JDK6之后是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过 JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

轻量级锁

多个线程在不同的时间段请求同一把锁,也就是说没有锁竞争。针对这种情况,JVM采用了轻量级锁,来避免线程的阻塞以及唤醒。

加锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。 如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。
在这里插入图片描述
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

重量级锁

重量级锁是JVM中为基础的锁实现。在这种状态下,JVM虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的 时候,唤醒这些线程。

Java线程的阻塞以及唤醒,都是依靠操作系统来完成的。举例来说,对于符合posix接口的操作系统(如macOS和绝大部分的Linux),上述操作通过pthread的互斥锁(mutex)来实现的。此外,这些操作将涉及系统调用,需要从操作系统 的用户态切换至内核态,其开销非常之大。

为了尽量避免昂贵的线程阻塞、唤醒操作,JVM会在线程进入阻塞状态之前,以及被唤醒之后竞争不到锁的情况 下,进入自旋状态,在处理器上空跑并且轮询锁是否被释放。如果此时锁恰好被释放了,那么当前线程便无须进入阻塞状态,而是直接获得这把锁。

三种锁特点

Java虚拟机中synchronized关键字的实现,按照代价由高到低可以分为重量级锁、轻量锁和偏向锁三种。

  • 偏向锁

只会在第一次请求锁时采用CAS操作并将锁对象的标记字段记录为当前线程地址。在此后的运行过程中,持有偏向锁的线程无需加锁操作。

针对的是锁仅会被同一线程持有的状况

  • 轻量级锁

采用CAS操作,将锁对象标记字段替换为一个指针,指向当前线程栈上的一块空间,存储着锁对象原本的标记字段。

针对的是多个线程在不同时间段申请同一把锁的情况

  • 重量级锁

会阻塞、唤醒请求加锁的线程。

针对的是多个线程同时竞争同一把锁的情况
JVM采用自适应自旋,来避免在面对非常小的同步代码块时,仍会被阻塞和唤醒的状况。

其他优化

锁粗化

锁粗化就是将多次连接在一起的加锁、解锁操作合并为一次,将多个连续的锁扩展成为一个范围更大的锁。

public class TestThread {
    private static StringBuffer sb = new StringBuffer();
    public static void main(String[] args) {
        sb.append("你好");
        sb.append("中国");
        sb.append("!!!");
    }
}

这里每次调用stringBuffer.append方法都需要加锁和解锁,如果虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

锁消除

锁消除即删除不必要的加锁操作。根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程, 那么可以认为这段代码是线程安全的,不必要加锁。

public class TestThread {
    public static void main(String[] args) {
        StringBuffer sb = new StringBuffer();
        sb.append("a").append("b").append("c");
    }
}

虽然StringBuffer的append是一个同步方法,但是这段程序中的StringBuffer属于一个局部变量,并且不会从该方法中 逃逸出去,所以其实这过程是线程安全的,可以将锁消除。

  • 10
    点赞
  • 40
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
优化是指在多线程编程中,通过改进锁的机制和使用方式来提高程序的性能和并发能力。synchronized关键字是Java中最常用的锁机制之一,它可以保证同一时间只有一个线程可以进入被synchronized修饰的代码块。下面是一些synchronized优化的方法: 1. 减小锁的粒度:如果在一个方法中有多个synchronized代码块,可以考虑将这些代码块拆分成多个方法,以减小锁的粒度。这样可以使得多个线程可以并发执行不同的代码块,提高程序的并发性能。 2. 使用局部变量替代成员变量:在使用synchronized关键字时,尽量使用局部变量而不是成员变量。因为成员变量的访问需要通过对象实例来进行,而局部变量的访问是线程私有的,不需要加锁。 3. 使用同步代码块代替同步方法:在某些情况下,使用同步代码块比使用同步方法更加灵活。同步代码块可以指定锁的粒度,只对需要同步的代码进行加锁,而不是整个方法。 4. 使用volatile关键字:volatile关键字可以保证变量的可见性和禁止指令重排序,可以在一定程度上替代synchronized关键字。但是需要注意,volatile关键字只能保证单个变量的原子性,不能保证多个操作的原子性。 5. 使用Lock接口:Java提供了Lock接口及其实现类ReentrantLock,相比于synchronized关键字,Lock接口提供了更加灵活的锁机制。可以手动控制锁的获取和释放,可以实现公平锁和非公平锁,并且支持多个条件变量。 6. 使用读写锁:如果在多线程环境下,读操作远远多于写操作,可以考虑使用读写锁ReadWriteLock来提高程序的并发性能。读写锁允许多个线程同时读取共享数据,但只允许一个线程写入共享数据。 7. 使用并发集合类:Java提供了一些并发集合类,ConcurrentHashMap、ConcurrentLinkedQueue等,它们内部使用了一些锁优化的技术,可以提高多线程环境下的并发性能。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值