多线程(3)—— 并发、同步、三大安全因素、锁

本文深入解析并发编程核心概念,如并发、同步与线程安全,探讨常见线程不安全问题及其实现案例,如买票、银行取款与集合操作。详述线程安全三大要素:原子性、内存可见性与代码重排序,以及解决方案synchronized关键字和Lock锁的使用技巧,对比分析两者的优劣,最后讨论死锁避免方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

并发: 两个或多个事件在同一时间间隔发生(同一个对象被多个线程同时操作)
同步: 多个任务情况下,一个任务A执行结束,才可以执行另一个任务B。同一时间只存在一个线程

线程同步

处理多线程问题时,多个线程访问同一个对象,并且某些线程还想修改这个对象,这时候我们就需要线程同步,线程同步其实就是等待机制 ,多个需要同时访问此对象的线程进入这个对象的等待池形成队列,等待前面线程使用完毕,下一个线程再使用形成条件:队列+锁

由于同一进程的多个线程共享同一块存储空间,在带来方便的同时,也带来了访问冲突问题,为了保证数据在方法中被访问时的正确性,在访问时加入锁机制synchronized ,当一个线程获得对象的锁,独占资源,其他线程必须等待,
使用后释放锁即可。
但存在以下问题:

  • 一个线程持有锁会导致其他所有需要此锁的线程挂起;

  • 在多线程竞争下,加锁,释放锁会导致比较多的上下文切换和调度延时,引起性能问题;

  • 如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能问题

三大线程不安全案例

不安全的买票问题

package com.it.syn;
//不安全的买票
public class UnsafeByTickets {

    public static void main(String[] args) {
        ByTicket ticket = new ByTicket();
        new Thread(ticket,"黄牛").start();
        new Thread(ticket,"用户").start();
        new Thread(ticket,"老李").start();
    }
}

class ByTicket implements Runnable {
    private int tickets = 10;
    boolean flag = true;


    @Override
    public void run() {
        while (flag) {
            buy();
        }
    }

    private void buy() {
        if (tickets<=0){
            flag = false;
            return;
        }
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName()+"买了第"+tickets--+"张票");
    }
}

执行结果:
在这里插入图片描述

不安全的银行取款问题

package com.it.syn;
//不安全的银行()
public class UnSafeBack {
    public static void main(String[] args) {
        Account account = new Account(100,"购房基金");
        Drawing you = new Drawing(account,50,"你");
        Drawing wife = new Drawing(account,100,"老婆");
        you.start();
        wife.start();
    }
}
//账户
class Account{
    int money; //余额
    String name;//卡名
    public Account(int money, String name) {
        this.money = money;
        this.name = name;
    }
}

//银行,模拟取款
class Drawing extends Thread{
    Account account;//账户
    int drawingMoney; //取了多少钱
    int nowMoney;  //手中的钱
    public Drawing (Account account,int drawingMoney,String name){
        super(name);
        this.account = account;
        this.drawingMoney = drawingMoney;
    }

    //取钱
    @Override
    public void run() {
        if (account.money - drawingMoney <  0 ){
            System.out.println(Thread.currentThread().getName()+"余额不足");
            return;
        }

        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
       account.money = account.money-drawingMoney;
        nowMoney = nowMoney + drawingMoney;

        System.out.println(account.name+"余额为:"+account.money);
        System.out.println(this.getName()+"手里的钱:"+nowMoney);
    }
}

执行结果:
在这里插入图片描述

线程不安全的集合

package com.it.syn;
import java.util.ArrayList;
import java.util.List;

//线程不安全集合
public class UnSafeList {
    public static void main(String[] args) {
        List<String> list = new ArrayList<String>();
        for (int i = 0; i < 1000; i++) {
            new Thread(()->{
                list.add(Thread.currentThread().getName());
            }).start();
        }
        System.out.println(list.size());
    }
}

执行结果:
在这里插入图片描述

出现线程不安全的原因

值都是随机变化的:

  • 线程什么时候被从 CPU上切换下来是随机的
  • 线程什么时候被切换到CPU上也是随机的
  • 随着计算的次数越大,需要的CPU的时间也就越多。可能被分到多个时间片的概率也就越大,所以出错的概率越大。

出现线程不安全的两个必要条件:

  • 线程之间是有数据的共享的
  • 即使线程之间出现了数据共享,只要大家都是只读(光读不修改),也不会有线程安全问题;即:只要对共享变量进行修改就会出现线程不安全。

线程安全三大因素

原文链接

原子性(atomic)

原子性(atomic):一组操作,不会被分割 或者 即使被分割之后,保证不会受到其他线程的干扰。
示例:

public class Unsafe1Thread {
    private static  int num;
    public static void main(String[] args) throws Exception {
        Thread A=new Thread(new Runnable() {
                    @Override
                    public void run() {
                        for(int i=0;i<1000;i++)
                        {
                            num++;
                        }
                    } }
        );
        Thread B=new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i=0;i<1000;i++)
                {
                    num--;
                }
            } }
        );
        A.start();   B.start();

        //等待线程A、B执行完
        A.join();   B.join();
        System.out.println(num);
    }
}

对于理想结果肯定是0,但是真实的结果每次都是随机数(是CPU调度线程是随机的),如下图所示:
在这里插入图片描述
出现这种原因是因为原子性被破坏了
对于(num++和num–)操作来说其实在底层被分为了三条指令:

  1. 从内存把数据读到 CPU
  2. 进行数据更新(+1或者-1)
  3. 把数据写回到内存

当我们使用start方法启动两条线程,每个线程在执行n++操作或者n- - 操作时,CPU可能随时切换,比如:线程A在执行++操作时刚执行了两条指令(load num 和 add num)CPU就从线程A切换出去,此时save num指令并没有执行;对于线程B,从内存中拿到的数据此时并不是线程A执行num++操作后的结果(++后的结果并没有写回内存),所以就破坏了线程A的原子性,导致数据不是预期的结果。

对于原子性问题,如何避免呢?

多线程直接不使用共享变量:让线程自己干自己的。
对共享变量只读不写
使用保证线程安全的机制

内存可见性

1. JMM(Java Memory Model——Java内存模型 )
JMM的设计是通过模拟计算机模型(模拟 CPU+高速缓存+内存)来设计的。

正常计算机模型:
在这里插入图片描述
JMM(工作内存+主内存):
在这里插入图片描述

  • JMM:内存被分为工作内存和主内存;要求:每个线程只能操作工作内存,不能直接操作主内存。
  • load n: 把n从主内存加载到工作内存中
  • add n: 在工作内存中执行计算
  • save n: 把n从工作内存同步回主内存;

2 . 可见性(visible)
在Java线程中涉及的内存可见性问题是基于JMM(工作内存+主内存)的。

线程执行过程: 会首先将数据从主内存加载到工作内存中,对数据的操作都是放在线程自己的工作内存中的。

可见性:某一个线程已经在工作内存中对变量做修改了,但其他线程没有感受到就产生了可见性问题。

我们写一段代码:

public class Unsafe2Thread {
    public static boolean running=true;
    private static class childThread extends Thread{
        @Override
        public void run(){
            int n=0;
            while (running)
            {
                n++;
            }
            System.out.println(n);
        }
    }
    public static void main(String[] args)throws Exception {
        Thread t=new childThread();
        t.start();

        Scanner scanner=new Scanner(System.in);
        System. out.print("随便输入什么,让子线程退出:");
        scanner. nextLine();

        System. out.println("running修改前:running:"+running+"   线程t的状态:"+t.getState()) ;

        running = false;
       while (true)
       {
           System. out.println("running修改后:running:"+running+"   线程t的状态:"+t.getState());
       }
    }
}

上面代码的作用是在主线程中修改running的值,看线程t是否会受影响。
运行结果:
在这里插入图片描述
我们发现,线程t并没有因为running的值改变发生状态改变。因为线程t是首先将running的值加载到自己的内存中,然后在自己的工作内存中执行代码,而主内存中改变了running的值,线程t在自己的工作内存中并看不到。

代码重排序(reordering)

什么叫做代码重排序?
你书写的代码顺序并不一定是最终执行代码的顺序。

为什么要进行代码重排序?
你的书写顺序不一定是最优解,所以为了提升效率,适当改变顺序可以接收。

代码重排序的基本要求
单线程情况,不能因为重排序,导致结果都不一样了。

在java中,谁会进行重排序?

  • 编译器(javac) 就会进行重排序——进行一部分的重排序。
  • JVM(JIT Just In Time即时编译器)——运行期间进行重排序.
  • CPU内部对真正要执行的指令,也会进行重排序。

重排序的底线是保证单线程情况下,没有副作用。它观察不到多线程的情况的,所以,重排序的结果可能会在多线程情下引发问题。

解决线程不安全问题

synchronized关键字

1.同步方法:

  • 由于我们可以通过private关键字来保证数据对象只能被方法访问,所以我们只需要针对方法提出一套机制,这套机制就是synchronized关键字,它包括两种用法:
    synchronized方法和synchronized块.
    同步方法:public synchronized void method(int args) {}
  • synchronized方法控制对“对象”的访问,每个对象对应一把锁,每个synchronized方法都必须获得调用该方法的对象的锁才能执行,否则线程会阻塞,方法一旦执行,就独占该锁,直到该方法返回才释放锁,后面被阻塞的线程才能获得这个锁,继续执行

缺陷: 若将一个大的方法申明为synchronized将会影响效率

2. 同步块: synchronized (Obj ) { }

  • Obj称之为同步监视器
  • Obj可以是任何对象,但是推荐使用共享资源作为同步监视器
  • 同步方法中无需指定同步监视器,因为同步方法的同步监视器就是this ,就是这个对象本身,或者是class
  • 同步监视器的执行过程
    • 第一个线程访问,锁定同步监视器,执行其中代码。
    • 第二个线程访问 ,发现同步监视器被锁定,无法访问。
    • 第一个线程访问完毕,解锁同步监视器。
    • 第二个线程访问,发现同步监视器没有锁,然后锁定并访问。

3. 注意: 锁的对象是需要增删改的对象

同步方法演示:
在上述 不安全的买票问题 中的 buy()方法上加入synchronized关键字

 //synchronized同步方法
    private synchronized void buy() throws InterruptedException {
        if (tickets<=0){
            flag = false;
            return;
        }
        Thread.sleep(100);
        System.out.println(Thread.currentThread().getName()+"买了第"+tickets--+"张票");
    }

同步块演示:
1.在上述不安全银行问题中的run方法内加入 synchronized (Obj ) { }

......
  //取钱
    @Override
    public void run() {

        synchronized (account){
            if (account.money - drawingMoney <  0 ){
                System.out.println(Thread.currentThread().getName()+"余额不足");
                return;
            }

            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            account.money = account.money-drawingMoney;
            nowMoney = nowMoney + drawingMoney;

            System.out.println(account.name+"余额为:"+account.money);
            System.out.println(this.getName()+"手里的钱:"+nowMoney);
        }
        }
 ......

在这里插入图片描述
2.在上述不安全集合问题中加入 synchronized (Obj ) { }
或者可以使用线程安全的集合:
java.util.concurrent.CopyOnWriteArrayList

public static void main(String[] args) {
    List<String> list = new ArrayList<String>();

    for (int i = 0; i < 1000; i++) {
        new Thread(()->{
            synchronized (list){
                list.add(Thread.currentThread().getName());
            }
        }).start();
    }
    try {
        Thread.sleep(200);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    System.out.println(list.size());
}

在这里插入图片描述

Lock (锁)

Lock (锁)

  • 从JDK 5.0开始,Java提供了更强大的线程同步机制一通过 显式定义同步锁对象来实现同步。同步锁使用Lock对象充当
  • java.util.concurrent.locks.Lock接口是控制多 个线程对共享资源进行访问的工具。
  • 锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象
  • ReentrantLock类实现了Lock ,它拥有与synchronized相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是ReentrantLock,可以显式加锁、释放锁。

代码演示:

package com.it;
import java.util.concurrent.locks.ReentrantLock;
//测试Lock锁
public class TestLock {
    public static void main(String[] args) {
        Lock2 lock2 = new Lock2();
        new Thread(lock2).start();
        new Thread(lock2).start();
        new Thread(lock2).start();
    }
}

class Lock2 implements Runnable{
    int tickets = 10;
    
    //定义Lock锁
    private final ReentrantLock lock = new ReentrantLock();

    @Override
    public void run() {
        while (true){
           try {
                //加锁
                lock.lock();
                if (tickets > 0){
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(tickets--);
                }else {
                    break;
                }
            }finally { //一把解锁写入 finally中
            	//解锁
                lock.unlock();
            }
        }
    }
}

synchronized与Lock的对比

  • Lock是显式锁(手动开启和关闭锁,别忘记关闭锁) synchronized是隐式锁,出了作用域自动释放
  • Lock只有代码块锁,synchronized有代码块锁和方法锁
  • 使用Lock锁,JVM将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性(提供更多的子类)
  • 优先使用顺序:
    Lock >同步代码块(已经进入了方法体,分配了相应资源) >同步方法(在方法体之外)

死锁

死锁
多个线程各自占有一些共享资源,并且互相等待其他线程占有的资源才能运行,而导致两个或者多个线程都在等待对方释放资源,都停止执行的情形。某一个同步块同时拥有“两个以上对象的锁”时,就可能会发生“死锁”的问题。

死锁避免方法
产生死锁的四个必要条件:

  • 互斥条件:一个资源每次只能被一个进程使用。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

上面列出了死锁的四个必要条件,我们只要想办法破坏其中的任意一个或多 个条件就可以避免死锁发生

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值