当我们进行多线程编程(比如使用 ThreadPool
线程池的方式创建多个线程处理业务)时,会存在多线程竞争资源导致的线程安全问题。
那如果代码中不使用多线程是不是就不会出现这些问题?
然而并发如此,在大多数使用 Java 创建的 Web 项目中,使用的 Web 容器(比如 Tomcat
)都是多线程的, 每一个进来的请求都需要一个线程,直到该请求结束。 这样一来,即使本身不打算使用多线程运行的代码,实际上几乎都会以多线程的方式执行。
我们知道 web 容器会以多线程的形式访问 JVM
,而在 JVM 管理的内存中,并不是所有内存都是线程私有的,比如 Heap
(Java堆)中的内存是所有线程共享的。
而 Heap 中主要是存放对象的,这样多个线程访问同一个对象时,就会使用到同一块内存了,在这块内存中存着的成员变量就会受到多个线程的操作,比如:
虽然多个线程访问的对象是在的同一块内存(这块内存可称为主内存),但是为了提高效率,每个线程有时会都会将读取到的值缓存在本线程内(具体因不同 JVM 的实现逻辑而有不同,所以缓存不是必然的),这些缓存的数据可称为副本数据。
这样,就会出现,某个值已经被某个线程更改了,但是其他线程却不知道,也不去主内存更新数据的情况,这样就导致了数据读取不一致的问题。
扩展知识:
内存的可见性:指多个线程同时访问同一个共享资源,可以感知到该资源被别的线程修改的动作。
在 Java 中,针对读不安全的问题提供了一个关键字 volatile
来解决问题,被 volatile 修饰的成员变量,在内容发生更改的时候,会通知所有线程去主内存更新最新的值,这样就解决了读不安全的问题,实现了读一致性。更多关于 volatile
关键字的介绍可以参考我的另一篇博客:【Java并发编程】之 Volatile 关键字
但是,读一致性是无法解决写一致性的问题,虽然能够使得每个线程都能及时获取到最新的值,但是写一致性问题还是会存在。
既然如此,Java 为啥还要提供 volatile 关键字呢?这并非多余的存在,在某些场景下只需要读一致性的话,这个关键字就能够满足需求而且性能相对还不错,因为其他的能够保证读写都一致的办法,多多少少都会牺牲一些性能。
Java 提供了三种方式来保证读写一致性:互斥锁、自旋锁、线程隔离。
互斥锁只是一个锁概念,它也可以被称为独占锁、排它锁、悲观锁等,其实就是同一个意思。它是指线程之间是互斥的,某一个线程获取了某个资源的锁,那么其他线程就只能等待锁的释放。
在 Java 中互斥锁的实现一般叫做同步线程锁,使用的关键字为 synchronized
,它锁住的范围是它所修饰的作用域,而锁住的对象可分为对象锁和类锁:
注意: 锁住的永远是对象,锁住的范围永远是 synchronized
关键字后面的花括号划定的代码域。
由于锁释放前,其他线程必将阻塞来保证锁住范围内的操作是原子性的(不可被中断的一个或一系列操作),所以同步线程锁的效率是最低的。
自旋锁同样也是一个锁概念,它也被称为乐观锁等。自旋锁本质上是不加锁的,而是通过对比旧数据来决定是否更新值:
以上的操作步骤也被称之为 CAS(Compare And Swap,比较交换)。在步骤3中,线程由于更新失败而在再次尝试更新的过程,就叫做自旋(表示操作失败后,线程会循环进行上一步的操作,直到成功为止)。
这种方式避免了线程的上下文切换以及线程互斥等,所以相对于互斥锁而言,它允许并发的存在(互斥锁不存在并发,只能同步进行)。
在 Java 的 java.util.concurrent.atomic
包中提供了自旋的操作类,比如:AtomicInteger
、AtomicLong
等,都能实现自旋的目的:
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();
}
}
}
但是,如果并发度很高的话,就会导致某些线程一直都无法更新成功(因为一直有其他线程更改了值),会使得线程长时间占用CPU和线程。所以自旋锁是属于低并发的解决方案。
另外,直接使用这些自旋的操作类还是太过原始,所以 Java 还在这个基础上封装了一些类,能够简单直接地接近于 synchronized
那么方便地对某段代码上锁,比如:ReentrantLock
和 ReentrantReadWriteLock
。
既然自旋锁只是低并发的解决方案,那么遇到高并发要如何处理呢?
答案是:将成员变量设成线程隔离的。也就是说每个线程都各自使用自己的变量,互相之间是不相关的,这样就做到了多线程安全。
在 Java 中提供了 ThreadLocal
类来实现线程隔离的效果,想了解更多关于 ThreadLocal 的细节可以参考我的另一篇博客:【Java并发编程】之ThreadLocal