Java性能优化(六)-多线程调优-乐观锁

简介: Java性能优化(六)-多线程调优-乐观锁
  • 作者简介:阿里非典型程序员一枚 ,记录在大厂的打怪升级之路。 一起学习Java、大数据、数据结构算法(公众号同名
  • ❤️觉得文章还不错的话欢迎大家点赞👍➕收藏⭐️➕评论,💬支持博主,记得点个大大的关注,持续更新🤞

引言

我们知道悲观锁在高并发的场景下,激烈的锁竞争会造成线程阻塞,大量阻塞线程会导致系统的上下文切换,增加系统的性能开销。那有没有可能实现一种非阻塞型的锁机制来保证线程的安全呢?答案是肯定的。今天我就带你学习下乐观锁的优化方法,看看怎么使用才能发挥它最大的价值。

乐观锁是什么

开始优化前,我们先来简单回顾下乐观锁的定义。

乐观锁,顾名思义,就是说在操作共享资源时,它总是抱着乐观的态度进行,它认为自己可以成功地完成操作。但实际上,当多个线程同时操作一个共享资源时,只有一个线程会成功,那么失败的线程呢?它们不会像悲观锁一样在操作系统中挂起,而仅仅是返回,并且系统允许失败的线程重试,也允许自动放弃退出操作。

所以,乐观锁相比悲观锁来说,不会带来死锁、饥饿等活性故障问题,线程间的相互影响也远远比悲观锁要小。更为重要的是,乐观锁没有因竞争造成的系统开销,所以在性能上也是更胜一筹。

乐观锁实现原理

CAS是实现乐观锁的核心算法,它包含了3个参数:V(需要更新的变量)、E(预期值)和N(最新值)。

只有当需要更新的变量等于预期值时,需要更新的变量才会被设置为最新值,如果更新值和预期值不同,则说明已经有其它线程更新了需要更新的变量,此时当前线程不做操作,返回V的真实值。

1.CAS如何实现原子操作

在JDK中的concurrent包中,atomic路径下的类都是基于CAS实现的。AtomicInteger就是基于CAS实现的一个线程安全的整型类。下面我们通过源码来了解下如何使用CAS实现原子操作。

我们可以看到AtomicInteger的自增方法getAndIncrement是用了Unsafe的getAndAddInt方法,显然AtomicInteger依赖于本地方法Unsafe类,Unsafe类中的操作方法会调用CPU底层指令实现原子操作。

//基于CAS操作更新值
    public final boolean compareAndSet(int expect, int update) {
        return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
    }
    //基于CAS操作增1
    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
    
    //基于CAS操作减1
    public final int getAndDecrement() {
        return unsafe.getAndAddInt(this, valueOffset, -1);
 

2.处理器如何实现原子操作

CAS是调用处理器底层指令来实现原子操作,那么处理器底层又是如何实现原子操作的呢?

处理器和物理内存之间的通信速度要远慢于处理器间的处理速度,所以处理器有自己的内部缓存。如下图所示,在执行操作时,频繁使用的内存数据会缓存在处理器的L1、L2和L3高速缓存中,以加快频繁读取的速度。

一般情况下,一个单核处理器能自我保证基本的内存操作是原子性的,当一个线程读取一个字节时,所有进程和线程看到的字节都是同一个缓存里的字节,其它线程不能访问这个字节的内存地址。

但现在的服务器通常是多处理器,并且每个处理器都是多核的。每个处理器维护了一块字节的内存,每个内核维护了一块字节的缓存,这时候多线程并发就会存在缓存不一致的问题,从而导致数据不一致。

这个时候,处理器提供了总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

当处理器要操作一个共享变量的时候,其在总线上会发出一个Lock信号,这时其它处理器就不能操作共享变量了,该处理器会独享此共享内存中的变量。但总线锁定在阻塞其它处理器获取该共享变量的操作请求时,也可能会导致大量阻塞,从而增加系统的性能开销。

于是,后来的处理器都提供了缓存锁定机制,也就说当某个处理器对缓存中的共享变量进行了操作,就会通知其它处理器放弃存储该共享资源或者重新读取该共享资源。目前最新的处理器都支持缓存锁定机制。

优化CAS乐观锁–使用LongAdder

背景

虽然乐观锁在并发性能上要比悲观锁优越,但是在写大于读的操作场景下,CAS失败的可能性会增大,如果不放弃此次CAS操作,就需要循环做CAS重试,这无疑会长时间地占用CPU。

在Java7中,通过以下代码我们可以看到:AtomicInteger的getAndSet方法中使用了for循环不断重试CAS操作,如果长时间不成功,就会给CPU带来非常大的执行开销。到了Java8,for循环虽然被去掉了,但我们反编译Unsafe类时就可以发现该循环其实是被封装在了Unsafe类中,CPU的执行开销依然存在。

public final int getAndSet(int newValue) {
        for (;;) {
            int current = get();
            if (compareAndSet(current, newValue))
                return current;
        }
    }

在JDK1.8中,Java提供了一个新的原子类LongAdder。LongAdder在高并发场景下会比AtomicIntegerAtomicLong的性能更好,代价就是会消耗更多的内存空间。

LongAdder介绍

LongAdder的原理就是降低操作共享变量的并发数,也就是将对单一共享变量的操作压力分散到多个变量值上,将竞争的每个写线程的value值分散到一个数组中,不同线程会命中到数组的不同槽中,各个线程只对自己槽中的value值进行CAS操作,最后在读取值的时候会将原子操作的共享变量与各个分散在数组的value值相加,返回一个近似准确的数值。

LongAdder内部由一个base变量和一个cell[]数组组成。当只有一个写线程,没有竞争的情况下,LongAdder会直接使用base变量作为原子操作变量,通过CAS操作修改变量;当有多个写线程竞争的情况下,除了占用base变量的一个写线程之外,其它各个线程会将修改的变量写入到自己的槽cell[]数组中,最终结果可通过以下公式计算得出:

我们可以发现,LongAdder在操作后的返回值只是一个近似准确的数值,但是LongAdder最终返回的是一个准确的数值, 所以在一些对实时性要求比较高的场景下,LongAdder并不能取代AtomicInteger或AtomicLong。

性能测试

综合上述条件,我将对四种模式下的五个锁Synchronized、ReentrantLock、ReentrantReadWriteLock、StampedLock以及乐观锁LongAdder进行压测。

这里简要说明一下:我是在不同竞争级别的情况下,用不同的读写线程数组合出了四组测试,测试代码使用了计算并发计数器,读线程会去读取计数器的值,而写线程会操作变更计数器值,运行环境是4核的i7处理器。结果已给出:

通过以上结果,我们可以发现:在读大于写的场景下,读写锁ReentrantReadWriteLock、StampedLock以及乐观锁的读写性能是最好的;在写大于读的场景下,乐观锁的性能是最好的,其它4种锁的性能则相差不多;在读和写差不多的场景下,两种读写锁以及乐观锁的性能要优于Synchronized和ReentrantLock。

总结

在数据库更新操作中,乐观锁通过版本号确保操作的原子性。然而,CAS乐观锁在处理多变量时受限,而悲观锁能对整个代码块加锁。在高并发写操作场景下,CAS的频繁失败会占用大量CPU资源。为此,JDK 1.8引入了LongAdder,通过分片技术提高并发性能,减少CAS竞争和失败。

欢迎一键三连(关注+点赞+收藏),技术的路上一起加油!!!代码改变世界

  • 关于我:阿里非典型程序员一枚 ,记录在大厂的打怪升级之路。 一起学习Java、大数据、数据结构算法公众号同名),回复暗号,更能获取学习秘籍和书籍等
相关文章
|
9天前
|
Java
Java—多线程实现生产消费者
本文介绍了多线程实现生产消费者模式的三个版本。Version1包含四个类:`Producer`(生产者)、`Consumer`(消费者)、`Resource`(公共资源)和`TestMain`(测试类)。通过`synchronized`和`wait/notify`机制控制线程同步,但存在多个生产者或消费者时可能出现多次生产和消费的问题。 Version2将`if`改为`while`,解决了多次生产和消费的问题,但仍可能因`notify()`随机唤醒线程而导致死锁。因此,引入了`notifyAll()`来唤醒所有等待线程,但这会带来性能问题。
Java—多线程实现生产消费者
|
11天前
|
安全 Java Kotlin
Java多线程——synchronized、volatile 保障可见性
Java多线程中,`synchronized` 和 `volatile` 关键字用于保障可见性。`synchronized` 保证原子性、可见性和有序性,通过锁机制确保线程安全;`volatile` 仅保证可见性和有序性,不保证原子性。代码示例展示了如何使用 `synchronized` 和 `volatile` 解决主线程无法感知子线程修改共享变量的问题。总结:`volatile` 确保不同线程对共享变量操作的可见性,使一个线程修改后,其他线程能立即看到最新值。
|
10天前
|
缓存 算法 搜索推荐
Java中的算法优化与复杂度分析
在Java开发中,理解和优化算法的时间复杂度和空间复杂度是提升程序性能的关键。通过合理选择数据结构、避免重复计算、应用分治法等策略,可以显著提高算法效率。在实际开发中,应该根据具体需求和场景,选择合适的优化方法,从而编写出高效、可靠的代码。
25 6
|
11天前
|
消息中间件 缓存 安全
Java多线程是什么
Java多线程简介:本文介绍了Java中常见的线程池类型,包括`newCachedThreadPool`(适用于短期异步任务)、`newFixedThreadPool`(适用于固定数量的长期任务)、`newScheduledThreadPool`(支持定时和周期性任务)以及`newSingleThreadExecutor`(保证任务顺序执行)。同时,文章还讲解了Java中的锁机制,如`synchronized`关键字、CAS操作及其实现方式,并详细描述了可重入锁`ReentrantLock`和读写锁`ReadWriteLock`的工作原理与应用场景。
|
10天前
|
并行计算 算法 安全
面试必问的多线程优化技巧与实战
多线程编程是现代软件开发中不可或缺的一部分,特别是在处理高并发场景和优化程序性能时。作为Java开发者,掌握多线程优化技巧不仅能够提升程序的执行效率,还能在面试中脱颖而出。本文将从多线程基础、线程与进程的区别、多线程的优势出发,深入探讨如何避免死锁与竞态条件、线程间的通信机制、线程池的使用优势、线程优化算法与数据结构的选择,以及硬件加速技术。通过多个Java示例,我们将揭示这些技术的底层原理与实现方法。
64 3
|
11天前
|
安全 Java 编译器
深入理解Java中synchronized三种使用方式:助您写出线程安全的代码
`synchronized` 是 Java 中的关键字,用于实现线程同步,确保多个线程互斥访问共享资源。它通过内置的监视器锁机制,防止多个线程同时执行被 `synchronized` 修饰的方法或代码块。`synchronized` 可以修饰非静态方法、静态方法和代码块,分别锁定实例对象、类对象或指定的对象。其底层原理基于 JVM 的指令和对象的监视器,JDK 1.6 后引入了偏向锁、轻量级锁等优化措施,提高了性能。
35 3
|
11天前
|
存储 安全 Java
Java多线程编程秘籍:各种方案一网打尽,不要错过!
Java 中实现多线程的方式主要有四种:继承 Thread 类、实现 Runnable 接口、实现 Callable 接口和使用线程池。每种方式各有优缺点,适用于不同的场景。继承 Thread 类最简单,实现 Runnable 接口更灵活,Callable 接口支持返回结果,线程池则便于管理和复用线程。实际应用中可根据需求选择合适的方式。此外,还介绍了多线程相关的常见面试问题及答案,涵盖线程概念、线程安全、线程池等知识点。
93 2
|
11天前
|
NoSQL Redis
单线程传奇Redis,为何引入多线程?
Redis 4.0 引入多线程支持,主要用于后台对象删除、处理阻塞命令和网络 I/O 等操作,以提高并发性和性能。尽管如此,Redis 仍保留单线程执行模型处理客户端请求,确保高效性和简单性。多线程仅用于优化后台任务,如异步删除过期对象和分担读写操作,从而提升整体性能。
35 1
|
3月前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
62 1
|
3月前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
41 3