• 【Java并发编程】之线程安全


    目录



    一、什么是线程安全问题


    1、为什么要考虑多线程安全问题

    当我们进行多线程编程(比如使用 ThreadPool 线程池的方式创建多个线程处理业务)时,会存在多线程竞争资源导致的线程安全问题。

    那如果代码中不使用多线程是不是就不会出现这些问题?

    然而并发如此,在大多数使用 Java 创建的 Web 项目中,使用的 Web 容器(比如 Tomcat)都是多线程的, 每一个进来的请求都需要一个线程,直到该请求结束。 这样一来,即使本身不打算使用多线程运行的代码,实际上几乎都会以多线程的方式执行。

    2、多线程安全问题的种类

    1)写不安全

    我们知道 web 容器会以多线程的形式访问 JVM,而在 JVM 管理的内存中,并不是所有内存都是线程私有的,比如 Heap(Java堆)中的内存是所有线程共享的。

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

    • 线程1读取对象中的变量 i 的值为10;同时,线程2同样读取该对象的变量 i 也为10;
    • 线程1将变量 i 的值增加2后将值改成了12;线程2同样对 i 的值进行修改,增加3后变成13(此时的线程2不知道线程1已经修改了 i 的值);
    • 因为是增加2和3,结果应该是15才对,但是因为多线程的原因,导致结果是12或13。
    2)读不安全

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

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

    扩展知识

    内存的可见性:指多个线程同时访问同一个共享资源,可以感知到该资源被别的线程修改的动作。


    二、如何保证多线程安全


    1、读一致性

    在 Java 中,针对读不安全的问题提供了一个关键字 volatile 来解决问题,被 volatile 修饰的成员变量,在内容发生更改的时候,会通知所有线程去主内存更新最新的值,这样就解决了读不安全的问题,实现了读一致性。更多关于 volatile 关键字的介绍可以参考我的另一篇博客:【Java并发编程】之 Volatile 关键字

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

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

    2、写一致性

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

    1)互斥锁

    互斥锁只是一个锁概念,它也可以被称为独占锁、排它锁、悲观锁等,其实就是同一个意思。它是指线程之间是互斥的,某一个线程获取了某个资源的锁,那么其他线程就只能等待锁的释放。

    在 Java 中互斥锁的实现一般叫做同步线程锁,使用的关键字为 synchronized,它锁住的范围是它所修饰的作用域,而锁住的对象可分为对象锁类锁

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

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

    由于锁释放前,其他线程必将阻塞来保证锁住范围内的操作是原子性的(不可被中断的一个或一系列操作),所以同步线程锁的效率是最低的

    2)自旋锁

    自旋锁同样也是一个锁概念,它也被称为乐观锁等。自旋锁本质上是不加锁的,而是通过对比旧数据来决定是否更新值:

    • 1、线程读取目标变量值,同时将变量值保存为旧值(old value);
    • 2、线程在对目标值进行修改操作时,会把旧值一起带过去比较变量当前所在内存的值;
    • 3、如果旧值和目标值相同则进行修改,如果不同则放弃修改,继续重复步骤1的操作直到修改成功。

    以上的操作步骤也被称之为 CAS(Compare And Swap,比较交换)。在步骤3中,线程由于更新失败而在再次尝试更新的过程,就叫做自旋表示操作失败后,线程会循环进行上一步的操作,直到成功为止)。

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

    在 Java 的 java.util.concurrent.atomic 包中提供了自旋的操作类,比如:AtomicIntegerAtomicLong 等,都能实现自旋的目的:

    public class MyTest {
    
    	private static volatile int anInt = 0;
    	private static AtomicInteger atomicInt = new AtomicInteger(0);
    
    	public static void main(String[] args) {
    		for (int i = 0; i < 3; i++) {
    			new Thread(new Runnable() {
    				@Override
    				public void run() {
    					// 每个线程对变量自增操作
    					for (int j = 0; j < 100; j++) {
    						// 普通变量在多线程中自增操作是不安全的
    						antInt++;
    						// 可以加上同步锁使其多线程安全
    						// synchronized (MyTest.class) {
    						//	  antInt++;
    						//}
    					}
    					for(int j = 0; j < 100; j++) {
    						// 自旋锁自增操作,是多线程安全的
    						atomicInt.incrementAndGet();
    					}
    				}
    			}).start();
    		}
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

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

    另外,直接使用这些自旋的操作类还是太过原始,所以 Java 还在这个基础上封装了一些类,能够简单直接地接近于 synchronized 那么方便地对某段代码上锁,比如:ReentrantLockReentrantReadWriteLock

    3)线程隔离

    既然自旋锁只是低并发的解决方案,那么遇到高并发要如何处理呢?

    答案是:将成员变量设成线程隔离的。也就是说每个线程都各自使用自己的变量,互相之间是不相关的,这样就做到了多线程安全。

    在 Java 中提供了 ThreadLocal 类来实现线程隔离的效果,想了解更多关于 ThreadLocal 的细节可以参考我的另一篇博客:【Java并发编程】之ThreadLocal

  • 相关阅读:
    Python爬虫
    数据结构-----图(graph)的储存和创建
    【特纳斯电子】智能台灯-实物设计
    数据库治理利器:动态读写分离
    JavaScript中多种获取数组最后一个元素的策略。
    源码分析:社区办和专业版的权限控制
    1.4.14 实验14:ospf多区域
    上周热点回顾(6.3-6.9)
    基于Python的数据分析系统的设计和实现
    【MicroPython】RP2040 MicroPython固件烧录以及Thonny 开发初探
  • 原文地址:https://blog.csdn.net/aiwangtingyun/article/details/126441968