Java 线程安全问题

发布时间:2023年12月18日

线程安全问题

两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果不一定为 0

@Slf4j(topic = "c.d1_problem")
public class d1_problem {
    static int counter = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter++;
            }
        }, "t1");
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter--;
            }
        }, "t2");
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        log.debug("{}",counter);
        // 不一定为 0
        // 输出 102 :21:04:59 [main] c.d1_problem - 102
    }
}

这里的共享变量 counter 的自增,自检并不是原子操作。是由多个步骤组成的,如果一个线程执行到其中一个步骤就停止了,另一个线程并不会接着上一个线程继续,而是重新开始算法(因为线程之间没有设置同步),此时获取到的counter值就不是最新的。

例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i

Java 内存模型中,每个线程有自己的本地内存,存放着共享变量的副本。要想要其他线程看见,还需要将自己本地内存的数据更新到主内存中,这一步如果没完成就开始上下文切换就会有问题:

ss

  • 黄色:线程 2 还没有写入共享变量的值到主内存中就发生了上下文切换
  • 红色:线程 2 对主内存的写 覆盖了线程 1 的写入值

ss

如果多个线程同时对共享变量读写,其中一个线程还没来得及将更改后的共享变量更新到主内存中,就切换到另一个线程从主内存中读该共享变量,此时读取的共享变量的值就是错的。而且切换回原来的线程后,之前被另一个线程更新后的共享变量的值也会被当前切换回来的线程所继续写的值给覆盖

临界区 Critical Section:

一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区

static int counter = 0;
static void increment()
// 临界区
{
counter++;
}
static void decrement()
// 临界区
{
counter--;
}

竞态条件 Race Condition

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

synchronized 解决方案

为了避免临界区的竞态条件发生,有多种手段可以达到目的。

  • 阻塞式的解决方案:synchronized,Lock
  • 非阻塞式的解决方案:原子变量

synchronized 采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换

语法:

synchronized(对象) // 线程1, 线程2(blocked)
{
	临界区
}

注意:

拥有锁对象的线程即使当前时间片被用完了也不会释放锁,而是等待下一次获得时间片继续运行临界区代码块,直到运行完成才会释放锁。在没有释放锁的期间,其他线程都是阻塞状态,释放锁后会唤醒其他线程将锁给其他线程执行其他临界区的代码

画图显示:

synchronized 实际是用对象锁保证了临界区内代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。

aa

如果把 synchronized(obj) 放在 for 循环的外面,如何理解?-- 原子性:整个 for 循环将受到保护,不可分割
如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?-- 锁对象:没有对 count进行保护
如果 t1 synchronized(obj) 而 t2 没有加会怎么样?如何理解?-- 锁对象:根本没有想去获得锁对象也就不会被阻塞住

Java中每一个对象都可以作为锁,具体表现为以下3种形式:

  1. 对于普通同步方法,锁是当前实例对象

    class Test{
        public synchronized void test() {
        }
    }
    等价于
    class Test{
        public void test() {
            synchronized(this) {
            }
        }
    }
    
  2. 对于同步方法块,锁是Synchronized括号里面指定的对象(指定的对象也可以用this,即指定当前实例对象)

  3. 对于静态同步方法,锁是当前类的Class对象

    class Test{
        public synchronized static void test() {
        }
    }
    等价于
    class Test{
        public static void test() {
            synchronized(Test.class) {
            }
        }
    }
    

变量的线程安全分析

成员变量和静态变量是否线程安全?

  • 如果它们没有共享,则线程安全
  • 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
    • 如果只有读操作,则线程安全
    • 如果有读写操作,则这段代码是临界区,需要考虑线程安全

局部变量是否线程安全?

  • 局部变量是线程安全的
  • 但局部变量引用的对象则未必
    • 如果该对象没有逃离方法的作用访问,它是线程安全的
    • 如果该对象逃离方法的作用范围,需要考虑线程安全

局部变量线程安全分析

public static void test1() {
    int i = 10;
    i++;
}

每个线程调用 test1() 方法时局部变量 i,会在每个线程的栈帧内存中被创建多份,因此不存在共享

saa

局部变量的引用可能会发生共享:

一个增加一个删除,就会发生线程安全问题:可能删除的时候集合为空

class d2_ThreadUnsafe {
    ArrayList<String> list = new ArrayList<>();
    public void method1(int loopNumber) {
        for (int i = 0; i < loopNumber; i++) {
            // { 临界区, 会产生竞态条件
            method2();
            method3();
            // } 临界区
        }
    }
    private void method2() {
        list.add("1");
    }
    private void method3() {
        list.remove(0);
    }

    static final int THREAD_NUMBER = 2;
    static final int LOOP_NUMBER = 200;
    public static void main(String[] args) {
        d2_ThreadUnsafe test = new d2_ThreadUnsafe();
        for (int i = 0; i < THREAD_NUMBER; i++) {
            new Thread(() -> {
                test.method1(LOOP_NUMBER);
            }, "Thread" + i).start();
        }
    }
}

分析:

引用类型的成员变量会被存储在堆中,每个线程都共享一份成员变量

而不是像基本的变量类型存储在栈中,每个线程都有一份自己的新的成员变量

在这里插入图片描述

将 list 修改为局部变量,则每个线程都拥有一份 新的 list,那么就没有线程安全问题:

class ThreadSafe {
    public final void method1(int loopNumber) {
        ArrayList<String> list = new ArrayList<>();
        for (int i = 0; i < loopNumber; i++) {
            method2(list);
            method3(list);
        }
    }
    private void method2(ArrayList<String> list) {
        list.add("1");
    }
    private void method3(ArrayList<String> list) {
        list.remove(0);
    }
}

分析:

  • list 是局部变量,每个线程调用时会创建其不同实例,没有共享
  • 而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象
  • method3 的参数分析与 method2 相同

在这里插入图片描述

如果把 method2 和 method3 的方法修改为 public

  • 情况1:有其它线程调用 method2 和 method3:没有哦线程安全问题,传入的list是不同的

  • 情况2:在 情况1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 或 method3 方法

    • class ThreadSafeSubClass extends ThreadSafe{
          @Override
          public void method3(ArrayList<String> list) {
              new Thread(() -> {
                  list.remove(0);
              }).start();
          }
      }
      
    • 存在线程安全问题:有两个线程操作同一个 list 了,可以通过添加 final 关键字防止子类重写

常见线程安全类

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent 包下的类

这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的

  • 它们的每个方法是原子的
  • 但注意它们多个方法的组合不是原子的

线程安全类方法的组合

Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
	table.put("key", value);
}

在这里插入图片描述

不可变类线程安全性

String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的

String 有 replace,substring 等方法并没有改变原有的值,而是创建了一个新的字符串赋给原来的变量,看起来改变了值

文章来源:https://blog.csdn.net/qq_45364953/article/details/135013358
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。