首页 > 其他分享 >线程的安全问题

线程的安全问题

时间:2024-03-30 11:59:59浏览次数:20  
标签:count Thread t1 问题 安全 线程 new public

目录

导言:

正文:

1.共享资源:

2.非原子操作:

3.执行顺序不确定:

4.可见性:

5.死锁和饥饿:

6.指令重排序:

总结:


导言:

线程安全是并发编程中的一个重要概念,它指的是在多线程环境下,对共享数据的访问和修改不会导致数据的不一致或其他不可预料的结果。在Java中,线程安全问题通常涉及到共享变量的访问和修改,以及多线程间的同步和协作。

正文:

1.共享资源

多线程程序中,多个线程可能同时访问并修改共享的数据结构、对象或变量。如果没有适当的同步机制,就会导致数据竞争问题。许多操作,如自增(++)、自减(--)、赋值等,虽然看起来是简单的操作,实际上在底层可能包含多个步骤(如读取值、修改值、写回值)。如果这些步骤在执行过程中被其他线程中断,就可能导致最终的值不符合预期。

代码实例:

public class test {
   private static int count;

    public static void main(String[] args) throws InterruptedException {
        //创建线程t1
        Thread t1 = new Thread(() -> {
           for (int i = 0; i < 50000; i++)
               count++;
        });
        //创建线程t2
        Thread t2 = new Thread(() -> {
            for(int i = 0; i < 50000; i++)
                count++;
        });
        //启动两个线程
        t1.start();
        t2.start();
        //保证两个线程能运行完
        t1.join();
        t2.join();
        //预期结果:10w
        System.out.println("count = " + count);
    }
}

这段代码的目的是让两个线程t1t2各自对静态变量count进行50000次自增操作,预期的最终结果是count的值变为100000。然而,这段代码存在线程安全问题,导致最终输出的count值每次都不一样。

问题的根源在于count++操作不是原子的。这个操作实际上包含了三个独立的步骤:

  1. 读取count当前的值。
  2. 增加该值。
  3. 将新值写回count

当多个线程并发执行count++操作时,可能会出现以下情况:

  • 线程A读取了count的值(假设为0)。
  • 线程B也读取了count的值(同样为0)。
  • 线程A增加其count的值到1,并写回内存。
  • 线程B增加其count的值到1,并写回内存。

在这种情况下,尽管两个线程都执行了count++操作,但count的最终值只增加了1,而不是2。这是因为两个线程可能读取到了相同的初始值,并且在增加和写回值的过程中没有适当的同步。

解决方法:

使用synchronized关键字,synchronized 关键字是 Java 中用于处理并发问题的同步机制之一。它可以确保同一时间只有一个线程能够访问被 synchronized 修饰的代码块或方法,从而解决多线程并发访问共享资源时的线程安全问题。

public class Test {
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        // 创建线程t1
        Thread t1 = new Thread(() -> {
            synchronized (countLock) {
                for (int i = 0; i < 50000; i++) {
                    count++;
                }
            }
        });
        // 创建线程t2
        Thread t2 = new Thread(() -> {
            synchronized (countLock) {
                for (int i = 0; i < 50000; i++) {
                    count++;
                }
            }
        });
        // countLock 是一个用来同步的静态对象
        private static final Object countLock = new Object();

        // 启动两个线程
        t1.start();
        t2.start();
        // 保证两个线程能运行完
        t1.join();
        t2.join();
        // 预期结果:10w
        System.out.println("count = " + count);
    }
}

在这个修改后的代码中,我们引入了一个静态对象 countLock 作为同步锁。两个线程在修改 count 变量时都会尝试获取这个锁对象的锁。当一个线程持有锁时,其他线程必须等待直到锁被释放。这样就保证了 count++ 的正确性。

2.非原子操作

非原子性操作指的是那些在执行过程中可以被其他线程中断的操作。在多线程环境中,非原子性操作可能导致竞态条件、数据不一致和其他线程安全问题。某些操作不是原子性的,即不能一次性完成所有操作。在多线程环境下,一个操作可能被多个线程交错执行,导致意外结果。

一条 java 语句不一定是原子的,也不一定只是一条指令

public class RaceConditionExample {
    private int sharedState = 0;

    public void increment() {
        sharedState++; // 非原子性操作
    }
}

在上面的例子中,increment 方法看起来是简单的自增操作,但实际上它包含三个独立的步骤:读取 sharedState 的值、增加值、写回新的值。如果有多个线程并发调用 increment 方法,sharedState 的值可能不会按预期递增。

解决办法同样是使用锁

public class SynchronizedSolution {
    private final Object lock = new Object();
    private int sharedState = 0;

    public void increment() {
        synchronized (lock) {
            sharedState++;
        }
    }
}

非原子性操作是多线程编程中常见的线程安全问题来源。解决这类问题的关键是通过使用 synchronized 关键字。

3.执行顺序不确定

多线程程序的执行顺序是不确定的,线程的调度是由操作系统和JVM控制的。线程的调度是随机的,这是线程安全问题的罪魁祸首。由于线程调度的随机性,即使是相同的程序在不同的执行环境下,或者在同一环境下不同的运行次数,都可能产生不同的结果。如果多个线程对共享资源的访问顺序不一致,就会产生不确定的结果。

解决这个问题的关键在于使用适当的同步机制来控制线程的执行顺序和访问共享资源的方式。通过使用 synchronized 关键字、原子类、并发集合类和其他并发工具,可以有效地避免由于执行顺序不确定性导致的线程问题。开发者应该在设计和实现多线程程序时充分考虑这些潜在问题,并采取适当的同步策略来确保程序的正确性和性能。

4.可见性

可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到。现代计算机系统中,每个CPU核心都有自己的缓存,这可能导致不同核心之间的数据不一致。当一个线程修改了共享变量的值,其他线程可能无法立即看到这个修改。下面进行更详细的说明:

先给出一幅图:

1.线程之间的共享变量存在 主内存 (Main Memory)。

2.每一个线程都有自己的 "工作内存" (Working Memory) 。

3.当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据。

4.当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存

由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 "副本"。此时修改线程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化。此时代码就会出现问题。同样使用锁即可解决这种问题。

5.死锁和饥饿

死锁是指多个线程或进程因争夺资源而造成的一种僵局,每个线程都在等待其他线程释放资源,导致所有线程都无法继续执行。死锁通常包含四个必要条件:互斥条件、请求与保持条件、不剥夺条件和循环等待条件。

饥饿是指一个或多个线程由于某种原因无法获取所需的资源,导致无法继续执行的情况。造成饥饿的原因可能包括优先级反转、资源竞争等。

public class DeadlockExample {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread 1 acquired lock1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("Thread 1 acquired lock2");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread 2 acquired lock2");
                synchronized (lock1) {
                    System.out.println("Thread 2 acquired lock1");
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

在上面的代码中,我们创建了两个线程thread1thread2,分别尝试获取lock1lock2,但它们的获取顺序不同,导致了死锁的发生。每个线程获取了一个锁,同时申请另一个锁导致两个进程永无止境的等待下去。

解决死锁问题的方法包括:

  • 预防死锁:设计良好的资源分配策略,破坏死锁的四个必要条件。
  • 避免死锁:通过安全序列算法等方法在运行时避免发生死锁。
  • 检测和恢复:通过检测死锁的发生,采取相应的措施打破死锁。

以下是对上述死锁问题的代码进行修改,通过调整获取锁的顺序来避免死锁的发生:

public class DeadlockSolution {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread 1 acquired lock1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("Thread 1 acquired lock2");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread 2 acquired lock1");
                synchronized (lock2) {
                    System.out.println("Thread 2 acquired lock2");
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

解决饥饿问题的方法包括:

  • 公平性:设计公平的资源分配策略,确保每个线程都有机会获取资源。
  • 优先级调度:通过优先级调度算法确保高优先级的线程能够及时获得所需的资源。
  • 资源复用:尽量减少资源的持有时间,避免资源长时间被占用而导致其他线程饥饿。

对于饥饿问题,可以通过设置线程的优先级或使用公平的锁来解决。以下是一个简单的代码,

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class StarvationSolution {
    private static final Lock fairLock = new ReentrantLock(true); // 使用公平锁

    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                try {
                    fairLock.lock();
                    System.out.println(Thread.currentThread().getName() + " acquired the fair lock");
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    fairLock.unlock();
                }
            }).start();
        }
    }
}

在上述代码中,我们使用ReentrantLock来创建一个公平锁,并在创建线程时指定使用公平锁。这样可以保证等待时间最长的线程会最先获取到锁,避免了饥饿问题的发生。 

6.指令重排序

指令重排序是现代处理器为了提高性能而采取的一种优化手段,它可以改变程序中指令的执行顺序,但不会改变程序的最终结果。然而,指令重排序可能会导致多线程程序出现一些意想不到的问题,如内存可见性问题、数据竞争等。

代码实例:

import java.util.Scanner;

public class test {
    static class Counter {
        public int flag = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            while (counter.flag == 0) {
            }
            System.out.println("循环结束!");
        });
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("输入一个整数:");
            counter.flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

在这个示例中,t1线程中不断地检查counter.flag是否为0,而t2线程负责从标准输入中读取一个整数并赋值给counter.flag。预期当用户输入非 0 的值的时候, t1 线程结束。实际上当用户输入非0值时, t1 线程循环不会结束 。

JVM可能会对指令进行重排序,导致t2线程中的赋值操作在t1线程看来发生在读取操作之前,从而t1线程永远无法看到t2线程修改的counter.flag值。

为了解决这个问题,可以通过以下方式进行修复:

  1. 使用volatile关键字修饰Counter类中的flag变量,确保线程之间的内存可见性。
  2. 使用synchronized关键字或Lock来保护共享变量的读写操作,确保线程安全。
  3. 使用wait()notify()等方法实现线程间的通信,避免忙等待的方式。

修复后的代码示例:

import java.util.Scanner;

public class test {
    static class Counter {
        public volatile int flag = 0;
    }

    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            while (counter.flag == 0) {
            }
            System.out.println("循环结束!");
        });
        Thread t2 = new Thread(() -> {
            Scanner scanner = new Scanner(System.in);
            System.out.println("输入一个整数:");
            counter.flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

通过将flag变量设置为volatile,可以确保线程之间对flag变量的可见性,避免出现数据不一致的情况。 

需要注意的是

指令重排序并不是一定会发生的。指令重排序是编译器或处理器为了提高程序执行性能而采取的一种优化手段,它可以在不影响单线程程序正确性的前提下,对指令的执行顺序进行调整。然而,这种优化并不是在所有情况下都会发生,而是根据具体的程序代码、编译器实现以及处理器特性来决定的。

编译器或处理器在进行指令重排序时,会遵循一定的规则和限制,以确保程序的正确性不受影响。例如,存在数据依赖关系的指令通常不会被重排序,因为这样做可能会改变程序的执行结果。此外,即使在允许重排序的情况下,编译器或处理器也可能会根据当前的执行环境和优化策略,选择不进行重排序。

总结:

线程安全问题是指在多线程环境下,当多个线程同时访问共享资源时可能导致的数据不一致、竞态条件、死锁等问题。为了解决线程安全问题,可以使用同步机制(如synchronized关键字、ReentrantLock等)来保护共享资源的访问,或者使用volatile关键字来确保共享变量的可见性。通过合理的设计和编码,可以有效地避免线程安全问题,确保多线程程序的正确性和稳定性。

标签:count,Thread,t1,问题,安全,线程,new,public
From: https://blog.csdn.net/weixin_49817511/article/details/137166974

相关文章

  • 解决在 VS Code 中无法自动导入 QApplication 类的问题
    起因在尝试使用VSCode来开发PySide6应用时,发现输入下面的代码时,没有触发Pylance的自动导入功能。app=QApplication()我期望的:#自动导入fromPySide6.QtWidgetsimportQApplication结果:什么都没有发生解决方法这个问题其实已经有人向Pylance扩展的开发者反......
  • 软件项目管理(开发/实施/运维/安全/交付)全套文档模板
      前言:在软件项目管理中,每个阶段都有其特定的目标和活动,确保项目的顺利进行和最终的成功交付。以下是软件项目管理各个阶段的详细资料:软件项目全套文档资料下载:点我获取1.需求阶段目标:收集、分析和定义用户需求和业务目标。主要活动:需求调研:与用户沟通,了解他们的需求......
  • 【洛谷】P1049 装箱问题
    P1049装箱问题确认所需算法题目链接:P1049装箱问题通过看标签得知这题是一道背包问题,如果你还不知道什么是背包问题,那么请看我这篇文章既然我们知道了这道题是一道背包问题,那么下一步我们要确认他是01背包还是完全背包。首先我们回顾01背包和完全背包的区别:通过题意可知,每......
  • 并发锁与线程池(二)
    前置内容:并发锁与线程池(一)1.互斥锁的实现#include<stdio.h>#include<pthread.h>#include<unistd.h>#defineTHREAD_COUNT 10pthread_mutex_tmutex;void*thread_callback(void*arg){ int*pcount=(int*)arg; inti=0; while(i++<100000)......
  • Vue父组件拿到接口的数据,并把数据传给子组件的问题;同时,父组件数据更新,子组件同样拿到
    参考文档:https://blog.csdn.net/qq_33723676/article/details/128143924问题一:父组件向子组件传值,子组件拿到的是空数据。在vue中,有时需要在父组件页面调用接口时,并把数据传给子组件。一般的做法中,子组件拿不到父组件传过来的值。原因是什么捏???原因就是:父组件跟子组件获取数据是......
  • Java面试必问题22:如何创建线程池(偏重点)&&创建线程池的注意事项
    企业最佳实践:不要使用Executors直接创建线程池,会出现OOM问题,要使用ThreadPoolExecutor构造方法创建,引用自《阿里巴巴开发手册》【强制】线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽......
  • Java面试必问题21:线程池核心参数
    publicThreadPoolExecutor(intcorePoolSize,                        intmaximumPoolSize,                        longkeepAliveTime,                        TimeUnitunit,        ......
  • Vuex的核心组成、版本问题及store.js的使用、 Vuex中存值、取值以及获取变量值、异步
    Vuex的核心组成、版本问题及store.js的使用、Vuex中存值、取值以及获取变量值、异步同步操作和Vuex后台交互  //store//初始值//设置值mutations  ---this.$store.commit('setDemoValue方法名',value); //更新值action --this.$store.disp......
  • openGauss 网络通信安全
    网络通信安全可获得性本特性自openGauss1.1.0版本开始引入。特性简介为保护敏感数据在Internet上传输的安全性,openGauss支持通过SSL加密客户端和服务器之间的通讯。客户价值保证客户的客户端与服务器通讯安全。特性描述openGauss支持SSL协议标准。SSL(SecureSocketLayer......
  • MES系统怎么解决车间生产调度难的问题?
    MES系统三个层次1、MES决定了生产什么,何时生产,也就是说它使公司保证按照订单规定日期交付准确的产品;2、MES决定谁通过什么方式(流程)生产,即通过优化资源配置,最有效运用资源;3、MES提供在什么时间已生产什么以及其他生产一线信息,以帮助后台管理系统ERP等进行进一步的分析。车......