【JAVA语言-第20话】多线程详细解析(二)——线程安全,非线程安全的集合转换成线程安全

目录

线程安全

[1.1 概述](#1.1 概述)

[1.2 案例分析](#1.2 案例分析)

[1.3 解决线程安全](#1.3 解决线程安全)

[1.3.1 synchronized关键字](#1.3.1 synchronized关键字)

[1.3.1.1 同步代码块](#1.3.1.1 同步代码块)

[1.3.1.2 同步方法](#1.3.1.2 同步方法)

[1.3.2 使用Lock锁](#1.3.2 使用Lock锁)

[1.3.2.1 概述](#1.3.2.1 概述)

代码示例

[1.4 线程安全的类](#1.4 线程安全的类)

[1.4.1 非线程安全集合转换成线程安全集合](#1.4.1 非线程安全集合转换成线程安全集合)


线程安全

1.1 概述

指如果有多个线程在同时运行,而这些线程可能会同时运行某段代码,程序每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全。

1.2 案例分析

那什么情况下会导致【线程不安全】呢?看如下案例:

**假设:**有三家电影院,卖票形式分别为以下A、B、C三种。

**思考:**哪一种卖票形式会出现问题呢?

  • 第1种:开一个窗口,卖100张票,不会出现问题,单线程程序不存在线程安全问题。
  • 第2种:开三个窗口,但是每个窗口票的号码不冲突,也不会出现问题,属于线程安全。
  • 第3种:开三个窗口,但是每个窗口票的号码一样。如果1,2,3三个窗口访问同一张票,那进入的结果和返回的结果很有可能不一致。这就出现了线程安全问题。

结论:

买票出现了线程安全问题,可能会出现重复的票和不存在的票,但是线程安全问题是不允许出现的。

1.3 解决线程安全问题

那怎么解决线程安全问题呢?

我们可以让一个线程在访问共享数据的时候,无论是否失去了CPU的执行权,让其他的线程只能等待,等待当前线程买完票,其他线程在进行买票。保证同时只有一个线程在买票。

1.3.1 使用synchronized关键字

在Java中,synchronized是一个关键字,用于控制多个线程对 对象或方法 的访问。当一个代码块被标记为synchronized时,只允许一个线程在同一时间执行该代码块。这样做是为了防止并发访问和潜在的数据损坏或不一致。该关键字可以使用在同步代码块或者同步方法用来解决线程安全问题。

1.3.1.1 同步代码块

一个同步代码块一次只允许一个线程进入,并确保它完成执行后其他线程才能进入。这是通过使用与同步代码块关联的对象的内在锁(或监视器)来实现的。

格式:

synchronized(锁对象){

可能会出现线程安全问题的代码(访问了共享数据的代码)

}

注意事项:

1.同步代码块中的锁对象,可以使用任意的对象。

2.必须保证多个线程使用的锁对象是同一个。

3.锁对象作用:把同步代码块锁住,只让一个线程在同步代码块中执行。

代码示例:

**RunnableImpl.java:**多线程的实现类

java 复制代码
package com.zhy.multiplethread;

public class RunnableImpl implements Runnable{
    /**
     * 共享票数
     */
    private int ticket = 10;

    /**
     * 设置线程任务:卖票
     */
    @Override
    public void run() {
        //使用死循环,让卖票操作重复执行
        while (true){
            //同步代码块,保证每次只有一个线程占用锁对象
            synchronized (this){
                //当存在余票时,进行卖票操作
                if (ticket > 0){
                    //为了表示卖票需要时间,暂停10毫秒
                    try {
                        Thread.sleep(10);
                    }catch (InterruptedException e){
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + " 正在卖第 " + ticket + "张票");
                    ticket--;
                }
            }//出了同步代码块,归还锁对象,供线程重新抢占
        }
    }
}

**TestThread.java:**线程测试类

java 复制代码
package com.zhy.multiplethread;

public class TestThread {
    public static void main(String[] args) {
        RunnableImpl impl = new RunnableImpl();
        Thread t1 = new Thread(impl);
        Thread t2 = new Thread(impl);
        Thread t3 = new Thread(impl);
        //开启3个线程一起抢夺CPU的执行权,谁抢到谁执行
        t1.start();
        t2.start();
        t3.start();
    }
}

**输出结果:**多个线程共同抢占CPU进行卖票操作,不会出现线程安全问题。

1.3.1.2 同步方法

当一个方法被声明为synchronized时,即使有多个线程同时访问该方法,也只允许一个线程执行。在这种情况下使用的锁是调用该方法的对象实例。

格式:

修饰符 synchronized 返回值类型 方法名(参数列表){

可能会出现线程安全问题的代码(访问了共享数据的代码)

}

使用步骤:

1.把访问了共享数据的代码抽取出来,放到一个方法中。

2.在方法上添加synchronized修饰符

代码示例:

**RunnableImpl.java:**多线程的实现类

java 复制代码
package com.zhy.multiplethread;

public class RunnableImpl implements Runnable{
    /**
     * 共享票数
     */
    private int ticket = 10;

    /**
     * 设置线程任务:卖票
     */
    @Override
    public void run() {
        //使用死循环,让卖票操作重复执行
        while (true){
            payTicket();
        }
    }

    /**
     * 同步方法:卖票
     */
    public synchronized void payTicket(){
        //当存在余票时,进行卖票操作
        if (ticket > 0){
            //为了表示卖票需要时间,暂停10毫秒
            try {
                Thread.sleep(10);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + " 正在卖第 " + ticket + "张票");
            ticket--;
        }
    }
}

结论:

通过使用synchronized关键字,我们可以确保在多线程环境中共享资源的安全访问。

1.3.2 使用Lock锁

1.3.2.1 概述

java.util.concurrent.locks.Lock接口:实现提供了比使用synchronized方法和语句可获得的更广泛的锁定操作。

Lock接口中的方法:

void lock():获取锁

void unlock():释放锁

实现类:

java.util.concurrent.locks.ReentrantLock implements Lock接口

使用步骤:

1.在成员位置创建一个ReentrantLock对象。

2.在可能会出现安全问题的代码前调用Lock接口中的方法lock获取锁。

3.在可能会出现安全问题的代码后调用Lock接口中的方法unlock释放锁。一般放在finally里面执行。

代码示例:

RunnableImpl.java:多线程实现类

java 复制代码
package com.zhy.multiplethread;

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

public class RunnableImpl implements Runnable{
    /**
     * 共享票数
     */
    private int ticket = 10;

    Lock l = new ReentrantLock();

    /**
     * 设置线程任务:卖票
     */
    @Override
    public void run() {
        //使用死循环,让卖票操作重复执行
        while (true){
            //获取锁:当存在余票时,进行卖票操作
            l.lock();
            try {
                if (ticket > 0) {
                    //为了表示卖票需要时间,暂停10毫秒
                    Thread.sleep(10);
                    System.out.println(Thread.currentThread().getName() + " 正在卖第 " + ticket + "张票");
                    ticket--;
                }
            }catch (InterruptedException e){
                e.printStackTrace();
            }finally {
                //释放锁:为了避免忘记释放或者出现异常,造成死锁,该操作放在finally中执行
                l.unlock();
            }
        }
    }
}

结论:

同步保证了只能有一个线程在同步中执行共享数据,保证率安全,但是程序频繁的判断锁、获取锁、释放锁、程序的效率会降低。

1.4 线程安全的类

如果一个类,所有的方法都是有synchronized修饰的,那么该类就叫做线程安全的类。保证同一时间,只有一个线程能够进入 这种类的一个实例 的去修改数据,进而保证了这个实例中的数据的安全,不会同时被多个线程修改而变成脏数据。

  • 操作集合的线程安全的类:Vector,Hashtable
  • 操作字符串的线程安全的类:StringBuffer

1.4.1 非线程安全集合转换成线程安全集合

ArrayList是非线程安全的,如果多个线程可以同时进入一个ArrayList对象的add/remove方法。那会造成什么后果呢,我们先看一个案例。

场景:

定义一个List集合,初始化5个元素。定义一个增加线程(往集合的头部持续插入1000个元素)和减少线程(从集合的头部持续移除1000个元素)同时操作该集合,我们最终想要的效果是:增加和减少的次数一致,最终集合内的元素仍然是初始化的元素。

代码示例:

java 复制代码
package com.zhy.multiplethread;

import com.zhy.thread.RunnableImpl;

import java.util.ArrayList;
import java.util.List;

public class TestThread {
    public static void main(String[] args) {
        //初始化List集合
        List<Integer> nonThreadSafeList = new ArrayList<Integer>();
        for (int i = 0; i < 5; i++){
            nonThreadSafeList.add(i + 3);
        }
        System.out.println("初始化List集合:" + nonThreadSafeList);

        //验证:使用两个线程同时往集合中插入1000个元素,在删除1000个元素
        int n = 1000;
        Thread[] addThreads = new Thread[n];
        Thread[] reduceThreads = new Thread[n];

        //将所有 增加线程 加入到addThreads数组中
        for (int i = 0; i < n; i++){
            Thread addThread = new Thread(){
                @Override
                public void run() {
                    nonThreadSafeList.add(0,1);
                    try {
                        //暂停1000毫秒,给其他线程抢占CPU的时间
                        Thread.sleep(1000);
                    }catch (InterruptedException e){
                        e.printStackTrace();
                    }
                }
            };
            addThread.start();
            addThreads[i] = addThread;
        }

        //将所有 减少线程 加入到reduceThreads数组中
        for (int i = 0; i < n; i++){
            Thread reduceThread = new Thread(new RunnableImpl(){
                @Override
                public void run() {
                    if (nonThreadSafeList.size() > 0){
                        nonThreadSafeList.remove(0);
                    }
                    try {
                        //暂停1000毫秒,给其他线程抢占CPU的时间
                        Thread.sleep(1000);
                    }catch (InterruptedException e){
                        e.printStackTrace();
                    }
                }
            });
            reduceThread.start();
            reduceThreads[i] = reduceThread;
        }

        //等待所有增加线程执行完成
        for (Thread addThread : addThreads){
            try {
                //将 增加线程 加入到主线程中
                addThread.join();
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        }

        //等待所有 减少线程 执行完成
        for (Thread reduceThread : reduceThreads){
            try {
                //将 减少线程 加入到主线程中
                reduceThread.join();
            }catch (InterruptedException e){
                e.printStackTrace();
            }
        }

        //所有增加线程 和 减少线程 执行完毕后,List集合中的数据:正确应该为初始数据
        System.out.println("所有增加线程 和 减少线程 执行完毕后,List集合中的数据:" + nonThreadSafeList);
    }
}

输出结果:

使用非线程安全的集合进行多线程处理,很显然最终的结果并不是我们想要的,出现了null的元素,且集合内的元素也不是初始化的元素。

**注:**并不是每一次执行都会出现错误的结果,多执行几次,会发现执行结果并不一致。

那如何把非线程安全的集合转换成线程安全的呢?

以ArrayList为例,使用Collections工具类中的synchronizedList,可以把ArrayList转换为线程安全的List。

源码:

public static <T> List<T> synchronizedList(List<T> list) ;

**使用:**Collections.synchronizedList(list);

改造上述代码,变成线程安全,只需加入如下代码,然后将多线程中操作的集合换成转换后的集合即可:

java 复制代码
        //将List转换成线程安全的类
        List<Integer> threadSafeList = Collections.synchronizedList(nonThreadSafeList);

最终的执行结果如下,执行多次,结果一致。

与此类似的,还有HashSet,LinkedList,HashMap等等非线程安全的类,具体类型如下,都可以通过Collections工具类转换为线程安全的


1.5 总结

在多线程中,线程安全问题是不允许被出现的。所以我们在使用多线程时,对于共享数据,可以通过synchronized关键字和Lock锁来处理,保证线程安全。 synchronized使用简单但灵活性较差;而Lock是一个更灵活的同步方式,可以实现更复杂的同步需求,但需要手动管理锁的获取和释放。在实际开发中,可以根据具体需求进行选择。

相关推荐
2401_8576100325 分钟前
Spring Boot框架:电商系统的技术优势
java·spring boot·后端
希忘auto41 分钟前
详解MySQL安装
java·mysql
娅娅梨44 分钟前
C++ 错题本--not found for architecture x86_64 问题
开发语言·c++
汤米粥1 小时前
小皮PHP连接数据库提示could not find driver
开发语言·php
冰淇淋烤布蕾1 小时前
EasyExcel使用
java·开发语言·excel
拾荒的小海螺1 小时前
JAVA:探索 EasyExcel 的技术指南
java·开发语言
Jakarta EE1 小时前
正确使用primefaces的process和update
java·primefaces·jakarta ee
马剑威(威哥爱编程)1 小时前
哇喔!20种单例模式的实现与变异总结
java·开发语言·单例模式
白-胖-子2 小时前
【蓝桥等考C++真题】蓝桥杯等级考试C++组第13级L13真题原题(含答案)-统计数字
开发语言·c++·算法·蓝桥杯·等考·13级
好睡凯2 小时前
c++写一个死锁并且自己解锁
开发语言·c++·算法