• Java下如何保证多线程安全


    前言

    可能有人会觉得,只要我写代码的时候不去开启其他线程,那么就不会有多线程的问题了。
    然而事实并非如此,如果仅仅是一些简单的测试代码,确实代码都会顺序执行而不是并发执行,但是Java应用最广泛的web项目中,绝大部分(如果不是所有的话)web容器都是多线程的——以tomcat为例, 每一个进来的请求都需要一个线程,直到该请求结束。 这样一来,即使本身不打算多线程运行的代码,实际上几乎都会以多线程的方式执行。
    在 Spring 注册的 bean(默认都是单例),在设为单例的 bean 中出现的成员变量或静态变量,都必须注意是否存在多线程竞争导致的多线程不安全的问题。
    ——可见,有些时候确实都是人在江湖,身不由己。积累多线程的知识是必不可少的。

    1.为什么会有多线程不安全的问题

    1.1.写不安全

    上面讲到 web 容器会多线程访问 JVM,这里还有一个问题,为什么多线程时就会存在多线程不安全呢?这是因为在 JVM 中的内存管理,并不是所有内存都是线程私有的,Heap(Java堆)中的内存是线程共享的。

    而 Heap 中主要是存放对象的,这样多个线程访问同一个对象时,就会使用到同一块内存了,在这块内存中存着的成员变量就会受到多个线程的操作。

    如下图所示:

     

     

    因为是增加2和3,结果应该是15才对,但是因为多线程的原因,导致结果是12或13。

    1.2.读不安全

    上面的写操作不安全是一方面,事实上 Java 中还存在更加糟糕的问题,就是读到的数据也不一致。

    因为多个线程虽然访问对象时是使用的同一块内存(这块内存可称为主内存),但是为了提高效率,每个线程有时会都会将读取到的值缓存在本线程内(具体因不同 JVM 的实现逻辑而有不同,所以缓存不是必然的),这些缓存的数据可称为副本数据。

    这样,就会出现,某个值已经被某个线程更改了,但是其他线程却不知道,也不去主内存更新数据的情况。

    如下图所示:

     

     

    上图的情况,其实线程的并发度相对要低一点,但即使是其他线程更改的数据,有的线程也不知道,因为读不安全导致了数据不一致。

    2.如何让多线程安全

    既然已经知道了会发生不安全的问题,那么要怎么解决这些问题呢?

    2.1.读一致性

    Java 中针对上述“读不安全”的问题提供了关键字 volatile 来解决问题,被 volatile 修饰的成员变量,在内容发生更改的时候,会通知所有线程去主内存更新最新的值,这样就解决了读不安全的问题,实现了读一致性。

    但是,读一致性是无法解决写一致性的,虽然能够使得每个线程都能及时获取到最新的值,但是1.1中的写一致性问题还是会存在。

    既然如此,Java 为啥还要提供 volatile 关键字呢?这并非多余的存在,在某些场景下只需要读一致性的话,这个关键字就能够满足需求而且性能相对还不错,因为其他的能够保证“读写”都一直的办法,多多少少存在一些牺牲。

    2.2.写一致性

    Java 提供了三种方式来保证读写一致性,分别是互斥锁、自旋锁、线程隔离。

    2.2.1.互斥锁

    互斥锁只是一个锁概念,在其他场景也叫做独占锁、悲观锁等,其实就是一个意思。它是指线程之间是互斥的,某一个线程获取了某个资源的锁,那么其他线程就只能睡眠等待。

    在 Java 中互斥锁的实现一般叫做同步线程锁,关键字 synchronized,它锁住的范围是它修饰的作用域,锁住的对象是: 当前对象(对象锁) 或 类的全部对象(类锁) ——锁释放前,其他线程必将阻塞,保证锁住范围内的操作是原子性的,而且读取的数据不存在一致性问题。

    • 对象锁:当它修饰方法、代码块时,将会锁住当前对象
    • 类锁:修饰类、静态方法时,则是锁住类的所有对象

    注意: 锁住的永远是对象,锁住的范围永远是 synchronized 关键字后面的花括号划定的代码域。

    2.2.2.自旋锁

    自旋锁也只是一个锁概念,在其他场景也叫做乐观锁等。

    自旋锁本质上是不加锁,而是通过对比旧数据来决定是否更新:

     

     

     

    如上所示,不管线程1与线程2哪个先执行,哪个后执行,结果都会是15,由此实现了读写一致性。而因为步骤3的更新失败而在步骤4中更新数据后再次尝试更新的过程,就叫做自旋——自旋只是个概念:表示 操作失败后,线程会循环进行上一步的操作,直到成功为止。

    这种方式避免了线程的上下文切换以及线程互斥等,相对于互斥锁而言,它允许并发的存在(互斥锁不存在并发,只能同步进行)。

    在 Java 的
    java.util.concurrent.atomic 包 中提供了自旋的操作类,诸如 AtomicInteger、AtomicLong 等,都能够达到此目的。

     

     

    1. 上面代码中的18行的代码,直接对一个int变量++操作,这是多线程不安全的
    2. 其中注释掉的19、20、21行代码则是加上了同步线程锁的写法,同步的操作使得多线程安全
    3. 下面的25行代码则是基于自旋锁的操作,也是多线程安全的

    但是,如果并发度很高的话,就会导致某些线程一直都无法更新成功(因为一直有其他线程更改了值),会使得线程长时间占用CPU和线程。所以自旋锁是属于低并发的解决方案。

    另外,直接使用这些自旋的操作类还是太过原始,所以Java还在这个基础上封装了一些类,能够简单直接地接近于 synchronized 那么方便地对某段代码上锁,即是 ReentrantLock 以及 ReentrantReadWriteLock,限于篇幅,这里不详细介绍他们的使用。

    2.2.3.线程隔离

    既然自旋锁只是低并发的解决方案,那么遇到高并发要如何处理呢?答案是将成员变量设成线程隔离的,也就是说每个线程都各自使用自己的变量,互相自己是不相关的。这样自然也做到了多线程安全。但是这种做法是让所有线程都互相隔离的了,所以他们之间是不存在互相操作的。

    在 Java 中提供了 ThreadLocal 类来实现这种效果:

    // 声明线程隔离的变量,变量类型通过泛型决定
    private static ThreadLocal<Integer> localInt = new ThreadLocal<>();
    
    // 获取泛型类的对象
    Integer integer = localInt.get();
    
    if (integer==null){
        integer = 0;
    }
    
    // 将泛型对象设到变量中
    localInt.set(++integer);

    总结

    本文主要讲了为什么会出现多线程不安全的原因,其中涉及读不安全与写不安全。Java 使用 volatile 关键字实现了读一致性,使用同步线程锁(synchronized)、自旋操作类(AtomicInteger等 )以及线程隔离类(ThreadLocal )来实现了写一致性,这三种方法中,同步线程锁效率最低,自旋操作类在非高并发的场景可大大提高效率,但是要想实现真正的高并发,还是需要用到线程隔离类来实现。

  • 相关阅读:
    0.1.3 set的用法
    JoinPoint
    砝码组合(dfs)
    强大的【环绕通知】
    applicationContext.xml 模板
    各种jar包
    装饰博客(二)添加宠物
    装饰博客(一)添加背景图片
    拖拽功能的实现
    点击之后连接qq
  • 原文地址:https://www.cnblogs.com/tiancai/p/16034825.html
Copyright © 2020-2023  润新知