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 )来实现了写一致性,这三种方法中,同步线程锁效率最低,自旋操作类在非高并发的场景可大大提高效率,但是要想实现真正的高并发,还是需要用到线程隔离类来实现。

posted @ 2022-03-21 15:11  甜菜波波  阅读(3155)  评论(0编辑  收藏  举报