【多线程综合】java何时考虑线程安全问题、怎么考虑、又怎么解决?

前言:在编程中,线程安全是一个非常重要的概念。它涉及到多个线程并发访问共享资源时的正确性和一致性。在Java中,为了确保线程安全,我们需要考虑一些关键因素。

1、什么是线程安全

线程安全是指当多个线程同时访问一个对象时,不会发生任何问题或者数据不一致的情况。这意味着每个线程都能正确地执行,并且最终得到正确的结果。现代计算机系统都是多核的,意味着多线程已经成为一种非常流行的编程方式。对于一个多线程程序来讲,线程安全是至关重要的,如果不考虑线程安全问题,可能会导致程序在某些情况下产生不可预测的、混乱的行为。

2.何时考虑线程安全

在Java中,我们需要考虑线程安全的情况有很多。下面是一些常见的场景:

1、共享资源:当多个线程同时访问共享的数据或对象时,需要考虑线程安全。例如,多个线程同时访问同一个变量或方法时。
2、并发访问集合类:Java提供了许多集合类,如ArrayList、HashMap等。当多个线程同时访问这些集合类时,需要考虑线程安全。否则可能会导致数据不一致或者异常。
3、多线程环境下的单例模式:在多线程环境下使用单例模式时,需要考虑线程安全。否则可能会创建多个实例,违反了单例模式的原则。

3、线程安全的实现方法*

线程安全的实现方法有以下几种:

2.1同步代码块

同步代码块是采用synchronized关键字来实现方法级别的同步。Java语言中,synchronized方法可以保证在同一时刻只有一个线程可以访问该方法,而同步代码块则可以保证在同一时刻只有一个线程可以访问该代码块,从而保证线程安全。

synchronized(共享资源对象){//对共享资源对象加锁
//代码(原子操作)
}
下面的共享资源是arr数组,{}大括号中是具体的操作。

注:
每个对象都有一个互斥锁标记,用来分配给线程的。
只有拥有对象互斥锁标记的线程,才可以进入该对象加锁的同步代码块。
线程退出同步代码块时,会释放相应的互斥锁标记。

例子:

package com.atguigu.gulimall.providerconsumer.util;

import lombok.extern.slf4j.Slf4j;

import java.util.Arrays;

/**
 * @author: jd
 * @create: 2024-07-16
 */
@Slf4j
public class MultiThreadTest {
    //初始数组下标值  全局变量
    private static int INDEX = 0;

public static void main(String[] args) throws InterruptedException {
//    synchronized   同步代码块

    //定义一个长度为5的数组
    String[] arr = new String[5];
    //定义两个线程,分别往数组中插入不同的值。
    //这里我们期望可以得到["ONE","TWO",null,null,null]  ,而不是得到["ONE",null,null,null,null] 或者["TWO",null,null,null,null]
    //创建线程一(以匿名内部类实现线程创建)
    Thread thread1 = new Thread(new Runnable() {
        @Override
        public void run() {
            //加锁的代码块,同一个时间只允许一个线程访问这个arr对象,并对其操作
            synchronized (arr){
                arr[INDEX] ="ONE";
                INDEX++;
            }
        }
    });

    //创建线程二(以匿名内部类+lamda表达式实现线程创建)
    Thread thread2 = new Thread(() -> {
        //加锁的代码块,同一个时间只允许一个线程访问这个arr对象,并对其操作
        synchronized (arr){
            arr[INDEX] ="TWO";
            INDEX++;
        }
    });
    //启动线程
    thread1.start();
    thread2.start();
    //这里为了保证输出时这两个线程已经执行完毕,使用join方法来阻塞主线程
    thread1.join();
    thread2.join();
//打印数据数据
    System.out.println(Arrays.toString(arr));
    
}

}

测试结果:
这时的结果无论执行多少次都不会出现元素覆盖的情况,结果为[ONE, TWO, null, null, null]或[TWO, ONE, null, null, null]。因为没有去控制线程执行顺序。
在这里插入图片描述
在这里插入图片描述
只有这两种结果,
如果我们把同步代码块给去掉的话,会出现,四个null的情况,但是概率挺低的。我见过一次,但是没截取下来 _

补充lamda表达式简化子线程的创建方式

  Thread t1 = new Thread(new Runnable() {
        @Override
        public void run() {
            // 这里是线程应该执行的代码
            System.out.println("Hello, World!");
        }
    });

    //上面代码t1的简化写法如下  见thread2创建
    Thread thread2 = new Thread(() -> {
        System.out.println("Hello, World!");
    });

    
2.2同步方法

同步方法是一种比同步代码块更加简单和方便的实现方式,通过给方法加上synchronized关键字,可以保证方法在被多个线程同时调用时,只有一个线程可以访问该方法,其它线程在等待该方法执行完毕后才能继续访问。

synchronized 返回值类型 方法名称(形参列表){//对当前对象this加锁
//代码(原子操作)
}

注:
只有拥有该对象互斥锁标记的线程才能进入该对象加锁的同步方法中。
线程退出同步方法时,会释放拥有的互斥锁标记。

例子:

package com.atguigu.gulimall.providerconsumer.util;

import lombok.extern.slf4j.Slf4j;

/**
 * @author: jd
 * @create: 2024-07-16
 */
@Slf4j
public class MultiThreadTest2 {

    //总票数
    private static int TICKET =100;

    public static void main(String[] args) {
        //定义售票窗口 t1,执行售票方法
        Thread t1 = new Thread(() -> {
            while (true) {
                if (!sale()) {
                    break;
                }
            }
        }, "t1");

        //定义售票窗口t2,执行售票方法
        Thread t2 = new Thread(() -> {
            while (true){
                if(!sale()){
                    break;
                }
            }
        }, "t2");

        //定义售票窗口t3,执行售票方法
        Thread t3 = new Thread(() -> {
            while (true) {
                if (!sale()) {
                    break;
                }
            }
        }, "t3");

        t1.start();
        t2.start();
        t3.start();

    }

    /**
     * 静态方法,锁的是当前方法所在的类
     * 非静态方法,锁的是this,调用该方法的对象。
     * @return
     */
    private synchronized static boolean sale() {
        if (TICKET <= 0) {
            return false;
        }
        System.out.println(Thread.currentThread().getName() + "销售了第" + TICKET + "张票");
        TICKET--;
        return true;
    }
}

现象:

t1销售了第100张票
t1销售了第99张票
t1销售了第98张票
t1销售了第97张票
t1销售了第96张票
t1销售了第95张票
t1销售了第94张票
t1销售了第93张票
t1销售了第92张票
t1销售了第91张票
t1销售了第90张票
t1销售了第89张票
t1销售了第88张票
t1销售了第87张票
t1销售了第86张票
t1销售了第85张票
t1销售了第84张票
t1销售了第83张票
t1销售了第82张票
t1销售了第81张票
t1销售了第80张票
t1销售了第79张票
t1销售了第78张票
t1销售了第77张票
t1销售了第76张票
t1销售了第75张票
t3销售了第74张票
t3销售了第73张票
t3销售了第72张票
t3销售了第71张票
t3销售了第70张票
t3销售了第69张票
t3销售了第68张票
t3销售了第67张票
t3销售了第66张票
t3销售了第65张票
t3销售了第64张票
t3销售了第63张票
t3销售了第62张票
t3销售了第61张票
t3销售了第60张票
t3销售了第59张票
t3销售了第58张票
t3销售了第57张票
t3销售了第56张票
t3销售了第55张票
t3销售了第54张票
t3销售了第53张票
t3销售了第52张票
t3销售了第51张票
t3销售了第50张票
t3销售了第49张票
t3销售了第48张票
t3销售了第47张票
t3销售了第46张票
t3销售了第45张票
t3销售了第44张票
t3销售了第43张票
t3销售了第42张票
t3销售了第41张票
t3销售了第40张票
t3销售了第39张票
t3销售了第38张票
t3销售了第37张票
t3销售了第36张票
t3销售了第35张票
t3销售了第34张票
t3销售了第33张票
t3销售了第32张票
t3销售了第31张票
t3销售了第30张票
t3销售了第29张票
t3销售了第28张票
t3销售了第27张票
t3销售了第26张票
t3销售了第25张票
t3销售了第24张票
t3销售了第23张票
t3销售了第22张票
t2销售了第21张票
t2销售了第20张票
t2销售了第19张票
t2销售了第18张票
t2销售了第17张票
t2销售了第16张票
t2销售了第15张票
t2销售了第14张票
t2销售了第13张票
t2销售了第12张票
t2销售了第11张票
t2销售了第10张票
t2销售了第9张票
t2销售了第8张票
t2销售了第7张票
t2销售了第6张票
t2销售了第5张票
t2销售了第4张票
t2销售了第3张票
t2销售了第2张票
t2销售了第1张票

Process finished with exit code 0

可以看到上面的是没有重复的;可以证明线程安全。

2.3volatile关键字

volatile关键字是一种比较特殊的同步实现方式,它可以用来修饰一个对象或者变量,使得其在多线程环境下的操作具有可见性。也就是说,通过使用volatile关键词修饰的变量,当一个线程修改了该变量的值,另一个线程就可以立刻看到这个变量的最新值,从而避免了多线程环境下没同步带来的数据不一致的问题。

2.3.1特点:

保证可见性:一个线程对Volatile变量的修改立即对其他线程可见。
不保证原子性:Volatile关键字不能替代Synchronized关键字,不能确保复合操作的原子性。
与其他同步机制的区别:相较于锁机制,Volatile具有轻量级和更好的性能。

2.3.2用途:

**标记共享变量:**当多个线程需要访问同一个变量,并且至少有一个线程会修改这个变量的值时,这个变量应该被声明为 volatile。这样可以确保当一个线程修改了变量的值后,其他线程能够立即看到这个修改。
**避免指令重排序:**在 Java 中,编译器和处理器可能会对代码进行指令重排序以提高性能。然而,在某些情况下,指令重排序可能会导致程序出现意外的行为,尤其是在多线程环境下。将变量声明为 volatile 可以防止编译器对包含该变量的代码进行指令重排序。
**作为同步的轻量级替代:**在某些情况下,如果不需要使用 synchronized 关键字带来的重量级锁(因为 synchronized 可能会导致线程阻塞和上下文切换),volatile 可以作为一个轻量级的替代方案。但是,需要注意的是,volatile 不能替代 synchronized 在所有场景下的使用,特别是当需要保证多个操作的原子性时。

2.3.3 例子:

假设有一个布尔变量 isRunning,用于控制某个线程的运行状态:

public class WorkerThread extends Thread {  
    private volatile boolean isRunning = true;  
  
    public void run() {  
        while (isRunning) {  
            // 执行任务  
        }  
    }  
  
    public void stopRunning() {  
        isRunning = false;  
    }  
}

在这个例子中,isRunning 被声明为 volatile,以确保当 stopRunning() 方法被调用时,isRunning 的新值能够立即被 run() 方法中的循环看到,从而正确地停止线程的执行。如果没有将 isRunning 声明为 volatile,那么由于编译器优化或缓存的影响,run() 方法中的循环可能无法及时看到 isRunning 的变化,导致线程无法停止。
注意事项
volatile 变量只能保证变量的可见性,但不能保证操作的原子性。例如,对于 volatile int count = 0;,count++ 操作就不是原子的,因为它包含了读取、修改和写入三个步骤。
在使用 volatile 时,需要仔细考虑其使用场景,避免错误地将其作为解决所有并发问题的万能药。

2.4线程安全集合

Java中提供了一些线程安全的集合类型,如ConcurrentHashMap和ConcurrentLinkedQueue等,这些集合在多线程并发访问时会自动对访问进行加锁,从而保证了线程安全。

4. 线程安全面临的挑战

在多线程编程中,线程安全不是一件容易的事情。多线程程序中需要解决以下挑战:

3.1 竞态条件

当多个线程试图同时访问和修改共享数据时,就可能会造成竞态条件(race condition)的问题,导致程序出现死锁、死循环、数据不一致等异常。为了避免出现竞态条件,需要采用一些同步技术来保证线程安全。

3.2 死锁

死锁是指两个或多个线程因为互相等待对方释放锁而陷入无限等待的状态。由于线程的执行顺序和时间是不确定的,因此在多线程应用程序中,要避免任何可能导致死锁的情况。

3.3 内存可见性

多个线程并发读写共享变量时,可能会由于线程之间的不同步而导致内存不可见性问题,即一个线程所做的修改对另外一个线程是不可见的。为了解决内存可见性问题需要采用一些同步技术(如synchronized关键字和volatile关键字)来保证线程安全。

5. 线程安全的应用场景

在实际开发中,需要注意以下几个方面来保证线程安全:

4.1 多个线程访问同一个对象时需要保证其线程安全。

如果多个线程需要访问同一个对象,那么就需要采用同步机制来保证该对象的线程安全。

4.2 多个线程访问多个对象时也需要保证线程安全。

虽然多个线程访问多个对象时不会产生死锁等问题,但是由于多个线程同时访问多个对象也可能会引发各种线程安全问题(如竞争条件、内存可见性问题等),因此对于多线程访问多个对象的场景同样需要注意线程安全问题。

4.3 线程池同样需要考虑线程安全。

在使用线程池时,需要注意要保证线程池本身的线程安全,同时要保证任务的线程安全。

4.4 对象的状态也需要考虑线程安全。

在编写多线程程序时,还需要注意对象状态的线程安全问题。具体来讲,就是需要考虑对象的状态可变性和不变性。对于可变对象,在多线程访问时需要进行同步处理;对于不可变对象则不存在线程安全问题。

线程安全在多线程编程中起着不可替代的重要作用。保证多线程程序的线程安全既需要采用相应的同步机制,也需要了解多线程程序面临的挑战及应用场景。同时,在软件开发中,也需要深入了解线程安全技术的实现原理,来避免出现因为线程安全而影响开发效率的问题。

  • 41
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值