【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();
		}
	}
}

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

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

3)线程隔离

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

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

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

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值