什么是线程安全

简介: 线程安全是多线程领域的问题,线程安全可以简单理解为一个方法或者一个实例可以在多线程环境中使用而不会出现问题。产生线程不安全的原因在同一程序中运行多个线程本身不会导致问题,问题在于多个线程访问了相同的资源。如,同一内存区(变量,数组,或对象)、系统(数据库,web services等)或文件。实际上,这些问题只有在一或多个线程向这些资源做了写操作时才有可能发生,只要

线程安全是多线程领域的问题,线程安全可以简单理解为一个方法或者一个实例可以在多线程环境中使用而不会出现问题。

产生线程不安全的原因

在同一程序中运行多个线程本身不会导致问题,问题在于多个线程访问了相同的资源。如,同一内存区(变量,数组,或对象)、系统(数据库,web services等)或文件。实际上,这些问题只有在一或多个线程向这些资源做了写操作时才有可能发生,只要资源没有发生变化,多个线程读取相同的资源就是安全的。

多线程同时执行下面的代码可能会出错:

public class Counter {
    protected long count = 0;

    public void add(long value){
        this.count = this.count + value;  
    }
}

想象下线程A和B同时执行同一个Counter对象的add()方法,我们无法知道操作系统何时会在两个线程之间切换。JVM并不是将这段代码视为单条指令来执行的,而是按照下面的顺序:

从内存获取 this.count 的值放到寄存器
将寄存器中的值增加value
将寄存器中的值写回内存

观察线程A和B交错执行会发生什么:

   this.count = 0;
   A:   读取 this.count 到一个寄存器 (0)
   B:   读取 this.count 到一个寄存器 (0)
   B:   将寄存器的值加2
   B:   回写寄存器值(2)到内存. this.count 现在等于 2
   A:   将寄存器的值加3
   A:   回写寄存器值(3)到内存. this.count 现在等于 3

两个线程分别加了2和3到count变量上,两个线程执行结束后count变量的值应该等于5。然而由于两个线程是交叉执行的,两个线程从内存中读出的初始值都是0。然后各自加了2和3,并分别写回内存。最终的值并不是期望的5,而是最后写回内存的那个线程的值,上面例子中最后写回内存的是线程A,但实际中也可能是线程B。如果没有采用合适的同步机制,线程间的交叉执行情况就无法预料。

竞态条件 & 临界区

当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。导致竞态条件发生的代码区称作临界区。上例中add()方法就是一个临界区,它会产生竞态条件。在临界区中使用适当的同步就可以避免竞态条件。

共享资源

允许被多个线程同时执行的代码称作线程安全的代码。线程安全的代码不包含竞态条件。当多个线程同时更新共享资源时会引发竞态条件。因此,了解Java线程执行时共享了什么资源很重要。

局部变量

局部变量存储在线程自己的栈中。也就是说,局部变量永远也不会被多个线程共享。所以,基础类型的局部变量是线程安全的。下面是基础类型的局部变量的一个例子:

public void someMethod(){
  long threadSafeInt = 0;
  threadSafeInt++;
}

局部的对象引用

上面提到的局部变量是一个基本类型,如果局部变量是一个对象类型呢?对象的局部引用和基础类型的局部变量不太一样。尽管引用本身没有被共享,但引用所指的对象并没有存储在线程的栈内,所有的对象都存在共享堆中,所以对于局部对象的引用,有可能是线程安全的,也有可能是线程不安全的。

那么怎样才是线程安全的呢?如果在某个方法中创建的对象不会被其他方法或全局变量获得,或者说方法中创建的对象没有逃出此方法的范围,那么它就是线程安全的。实际上,哪怕将这个对象作为参数传给其它方法,只要别的线程获取不到这个对象,那它仍是线程安全的。下面是一个线程安全的局部引用样例:

public void someMethod(){
  LocalObject localObject = new LocalObject();
  localObject.callMethod();
  method2(localObject);
}

public void method2(LocalObject localObject){
  localObject.setValue("value");
}

上面样例中LocalObject对象没有被方法返回,也没有被传递给someMethod()方法外的对象,始终在someMethod()方法内部。每个执行someMethod()的线程都会创建自己的LocalObject对象,并赋值给localObject引用。因此,这里的LocalObject是线程安全的。事实上,整个someMethod()都是线程安全的。即使将LocalObject作为参数传给同一个类的其它方法或其它类的方法时,它仍然是线程安全的。当然,如果LocalObject通过某些方法被传给了别的线程,那它就不再是线程安全的了。

对象成员对象成员存储在堆上。如果两个线程同时更新同一个对象的同一个成员,那这个代码就不是线程安全的。下面是一个样例:

public class NotThreadSafe{
    StringBuilder builder = new StringBuilder();

    public add(String text){
        this.builder.append(text);
    }  
}

如果两个线程同时调用同一个NotThreadSafe实例上的add()方法,就会有竞态条件问题。例如:

NotThreadSafe sharedInstance = new NotThreadSafe();

new Thread(new MyRunnable(sharedInstance)).start();
new Thread(new MyRunnable(sharedInstance)).start();

public class MyRunnable implements Runnable{
  NotThreadSafe instance = null;

  public MyRunnable(NotThreadSafe instance){
    this.instance = instance;
  }

  public void run(){
    this.instance.add("some text");
  }
}

注意两个MyRunnable共享了同一个NotThreadSafe对象。因此,当它们调用add()方法时会造成竞态条件。

当然,如果这两个线程在不同的NotThreadSafe实例上调用call()方法,就不会导致竞态条件。下面是稍微修改后的例子:

new Thread(new MyRunnable(new NotThreadSafe())).start();
new Thread(new MyRunnable(new NotThreadSafe())).start();

现在两个线程都有自己单独的NotThreadSafe对象,访问的不是同一资源,不满足竞态条件,是线程安全的。所以非线程安全的对象仍可以通过某种方式来消除竞态条件。

判断资源对象是否是线程安全

线程控制逃逸规则可以帮助你判断代码中对某些资源的访问是否是线程安全的。

如果一个资源的创建,使用,销毁都在同一个线程内完成,且永远不会脱离该线程的控制,则该资源的使用就是线程安全的。

资源可以是对象,数组,文件,数据库连接,套接字等等。Java中我们无需主动销毁对象,所以“销毁”指不再有引用指向对象。

注意即使对象本身线程安全,但如果该对象中包含其他资源(文件,数据库连接),整个应用也许就不再是线程安全的了。比如2个线程都创建了各自的数据库连接,每个连接自身是线程安全的,但它们所连接到的同一个数据库也许不是线程安全的。比如,2个线程执行如下代码:

检查记录X是否存在,如果不存在,插入X

如果两个线程同时执行,而且碰巧检查的是同一个记录,那么两个线程最终可能都插入了记录:

线程1检查记录X是否存在。检查结果:不存在
线程2检查记录X是否存在。检查结果:不存在
线程1插入记录X
线程2插入记录X

同样的问题也会发生在文件或其他共享资源上。因此,区分某个线程控制的对象是资源本身,还是仅仅到某个资源的引用很重要。

不可变的共享资源

当多个 线程同时访问同一个资源,并且其中的一个或者多个线程对这个资源进行了写操作,才会产生竞态条件。多个线程同时读同一个资源不会产生竞态条件。

我们可以通过创建不可变的共享对象来保证对象在线程间共享时不会被修改,从而实现线程安全。如下示例:

public class ImmutableValue{
    private int value = 0;

    public ImmutableValue(int value){
        this.value = value;
    }

    public int getValue(){
        return this.value;
    }
}

如果你需要对ImmutableValue类的实例进行操作,如添加一个类似于加法的操作,我们不能对这个实例直接进行操作,只能创建一个新的实例来实现,下面是一个对value变量进行加法操作的示例:

public class ImmutableValue{
    private int value = 0;

    public ImmutableValue(int value){
        this.value = value;
    }

    public int getValue(){
        return this.value;
    }

    public ImmutableValue add(int valueToAdd){
        return new ImmutableValue(this.value + valueToAdd);
    }
}

请注意add()方法以加法操作的结果作为一个新的ImmutableValue类实例返回,而不是直接对它自己的value变量进行操作。

引用不是线程安全的!

重要的是要记住,即使一个对象是线程安全的不可变对象,指向这个对象的引用也可能不是线程安全的。看这个例子:

public void Calculator{
    private ImmutableValue currentValue = null;

    public ImmutableValue getValue(){
        return currentValue;
    }

    public void setValue(ImmutableValue newValue){
        this.currentValue = newValue;
    }

    public void add(int newValue){
        this.currentValue = this.currentValue.add(newValue);
    }
}

Calculator类持有一个指向ImmutableValue实例的引用。注意,通过setValue()方法和add()方法可能会改变这个引用,因此,即使Calculator类内部使用了一个不可变对象,但Calculator类本身还是可变的,多个线程访问Calculator实例时仍可通过setValue()和add()方法改变它的状态,因此Calculator类不是线程安全的。

换句话说:ImmutableValue类是线程安全的,但使用它的类则不一定是。当尝试通过不可变性去获得线程安全时,这点是需要牢记的。

要使Calculator类实现线程安全,将getValue()setValue()add()方法都声明为同步方法即可。

Java中实现线程安全的方法

在Java多线程编程当中,提供了多种实现Java线程安全的方式:

  • 最简单的方式,使用Synchronization关键字:Java Synchronization介绍
  • 使用java.util.concurrent.atomic 包中的原子类,例如 AtomicInteger
  • 使用java.util.concurrent.locks 包中的锁
  • 使用线程安全的集合ConcurrentHashMap
  • 使用volatile关键字,保证变量可见性(直接从内存读,而不是从线程cache读)
目录
相关文章
|
Ubuntu Shell 开发工具
Ubuntu 20.04 配置 zsh
Ubuntu 20.04 配置 zsh
1017 0
Ubuntu 20.04 配置 zsh
|
安全 开发者 C++
Windows10安装modelscope后import这个模块报错:OSError: [WinError 1] 函数不正确。: 'D:\Anaconda\envs\MDS\lib\site-packages\modelscope\utils\ast_utils.py'
Windows10安装modelscope后import这个模块报错:OSError: [WinError 1] 函数不正确。: 'D:\Anaconda\envs\MDS\lib\site-packages\modelscope\utils\ast_utils.py'
765 1
|
Linux 数据安全/隐私保护 C语言
新手向导:轻松离线搭建最新版OpenVPN(含一键安装脚本)
OpenVPN 是常用的虚拟私有网络工具,通过 Docker 搭建非常简单。但常用的 kylemanna/openvpn 镜像已三年未更新,停留在 OpenVPN 2.4 版本。为了升级到最新版本(如 2024 年 2 月发布的 v2.6.9),可以通过官方开源社区获取最新安装包并手动编译安装。步骤包括安装依赖、下载并编译 OpenSSL 和 OpenVPN、生成证书和配置文件等。此外,GitHub 上有一键安装脚本 openvpn-install.sh,简化了安装过程,但其版本可能不是最新的。安装完成后,还需配置 iptables 以确保客户端能正常使用代理网络。
15929 1
|
Linux 数据安全/隐私保护
openstack 上创建云主机
该内容是关于使用OpenStack创建云实例的步骤指南。首先,提供了CentOS 7的镜像源,并建议用户自行封装qcow2格式镜像。接着,展示了通过`cat keystonerc_admin`获取OpenStack的管理员用户名和密码。然后,详细描述了在OpenStack界面中创建网络、子网和路由的过程,以连接到外部网络。最后,指导用户上传qcow2镜像,创建并配置实例,包括选择镜像、实例类型和网络,最终等待实例创建完成。
980 1
openstack 上创建云主机
|
Ubuntu 数据安全/隐私保护 虚拟化
Ubuntu和Windows之间文件无损互传
Ubuntu和Windows之间文件无损互传
1353 0
|
JavaScript Java Maven
理解固化的Maven依赖:spring-boot-starter-parent 与 spring-boot-dependencies
理解固化的Maven依赖:spring-boot-starter-parent 与 spring-boot-dependencies
7122 1
|
数据采集 算法 网络架构
英伟达开源大模型FoundationPose称霸BOP排行榜
【4月更文挑战第10天】英伟达新推出的FoundationPose模型在6D对象姿态估计和跟踪上取得重大突破,荣登BOP排行榜首。该模型以统一框架兼容有模型和无模型设置,利用大规模合成数据和对比学习提高泛化能力,且在复杂场景中表现出高适应性。尽管在处理某些困难情况时仍有局限,如无纹理物体的定位,但它展示了巨大的潜力和对未来技术的启示。
545 1
英伟达开源大模型FoundationPose称霸BOP排行榜
|
安全 Java 测试技术
springboot @Transactional的概念以及实战
【4月更文挑战第25天】在Spring Boot中,@Transactional注解是用来声明一个方法或类中所有公共方法的事务边界。这个注解主要由Spring框架提供的声明式事务管理机制支持。使用@Transactional可以极大简化事务管理的复杂性,确保数据的一致性和完整性。
366 0
|
前端开发 Java Spring
@RequestMapping, @GetMapping, 和 @PostMapping区别
@RequestMapping, @GetMapping, 和 @PostMapping区别
|
JavaScript Java 测试技术
基于微信小程序的二手物品交易平台ssm附带文章和源代码设计说明文档ppt
基于微信小程序的二手物品交易平台ssm附带文章和源代码设计说明文档ppt
350 0