Java基础快速入门: 多线程安全

本文纲要

  1. 线程安全问题 – 卖票案例实现
  2. 线程安全问题 – 原因分析
  3. 同步代码块
  4. 锁对象的唯一性
  5. 同步方法
  6. Lock
  7. 死锁

卖票案例实现

1 ) 需求

模拟电影院售票:全场共 100 张票,有 3 个窗口 同时卖票。要求使用多线程来设计程序。

2 ) 基础设计

  • 三个线程代表三个窗口。
  • 共享数据为 100 张票。
  • 使用实现 Runnable 接口的方式创建线程,保证多个线程操作同一个任务对象。

3 ) 初始代码(存在安全问题)

下面的代码通过 Runnable 共享一个 Ticket 对象,每个线程在 run() 中循环卖票。为了更真实地模拟出票耗时,我们加入 Thread.sleep(100)但此时尚未加锁,会暴露线程安全问题

public class Ticket implements Runnable {
    private int ticket = 100;
 
    @Override 
    public void run() {
        while (true) {
            if (ticket <= 0) {
                break;
            } else {
                try {
                    Thread.sleep(100);   // 模拟出票耗时 
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                ticket--;
                System.out.println(Thread.currentThread().getName() 
" 在卖票, 还剩下 " + ticket + " 张票");
            }
        }
    }
}

测试类:

public class Demo {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();  // 只创建一个任务对象 
 
        Thread t1 = new Thread(ticket, "窗口一");
        Thread t2 = new Thread(ticket, "窗口二");
        Thread t3 = new Thread(ticket, "窗口三");
 
        t1.start();
        t2.start();
        t3.start();
    }
}

运行结果会出现 重复票负数票 的现象。这是典型的线程安全问题。

原因分析

1 ) 为什么出现重复票?

我们通过下图分析三个线程的执行交错情况。

ticket = 100线程3 (蓝)线程2 (红)线程1 (绿)ticket = 100线程3 (蓝)线程2 (红)线程1 (绿)三个线程都读到 100多个线程打印的剩余票数都是 97,出现重复抢到 CPU,读取 ticket=100sleep(100)抢到 CPU,读取 ticket=100sleep(100)抢到 CPU,读取 ticket=100sleep(100)ticket-- → 99准备打印 99(CPU 被抢走)ticket-- → 98(基于 T1 减后的值)准备打印 98(CPU 被抢走)ticket-- → 97打印 97

原因:多个线程在“判断 → sleep → 减减 → 打印”过程中交替执行,导致多个线程读到了同一个 ticket 值,减减后打印出相同的数。

2 ) 为什么出现负数票?

ticket = 1 时,三条线程同样可能交错执行。假设流程如下:

ticket = 1线程3线程2线程1ticket = 1线程3线程2线程1出现负数票判断 ticket>0,进入 elsesleep(100)判断 ticket>0,进入 elsesleep(100)判断 ticket>0,进入 elsesleep(100)ticket-- → 0,打印“剩余0张”ticket-- → -1,打印“剩余-1张”ticket-- → -2,打印“剩余-2张”

根本原因:多条语句在操作共享数据时,未能保证 原子性,被其他线程穿插执行。

同步代码块

1 ) 解决思想

将操作共享数据的代码 锁起来,任一时刻只允许一个线程进入执行,执行完释放锁后下一个线程才能进入。

2 ) 语法格式

synchronized(锁对象) {
    // 操作共享数据的代码 
}
  • 锁对象可以是任意对象,但多个线程必须 使用同一把锁
  • 当线程进入同步块,锁自动关闭;执行完代码块,锁自动打开。

3 ) 改进代码

Ticket 类中引入一把锁 obj = new Object(),用 synchronized 包裹卖票逻辑:

public class Ticket implements Runnable {
    private int ticket = 100;
    private Object obj = new Object();
 
    @Override 
    public void run() {
        while (true) {
            synchronized (obj) {           // 多个线程使用同一把锁 
                if (ticket <= 0) {
                    break;                // 卖完结束 
                } else {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    ticket--;
                    System.out.println(Thread.currentThread().getName()
" 在卖票, 还剩下 " + ticket + " 张票");
                }
            }
        }
    }
}

测试类不变,此时运行结果不再出现重复票或负数票,线程安全问题得以解决。

项目结构

threadmodule/src/com/wb/threaddemo9/
├── Ticket.java 
└── Demo.java 

锁对象的唯一性

如果使用 继承 Thread 的方式创建线程,由于每个线程是一个独立对象,使用 this 作为锁就不再唯一。下面演示这个错误及修复方法。

1 ) 错误示例(使用 this 作为锁)

public class MyThread extends Thread {
    private int ticketCount = 100;      // 非静态,各对象独立 
 
    @Override 
    public void run() {
        while (true) {
            synchronized (this) {       // this 是各自不同的线程对象 
                if (ticketCount <= 0) {
                    break;
                } else {
                    try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }
                    ticketCount--;
                    System.out.println(getName() + " 在卖票, 还剩下 " + ticketCount + " 张票");
                }
            }
        }
    }
}

测试类:

MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
t1.start();
t2.start();

由于 t1t2 是两个对象,synchronized(this) 实际是两把不同的锁,同步失效,仍会出现重复票和负数票。

2 ) 正确做法:使用静态变量 + 静态锁

public class MyThread extends Thread {
    private static int ticketCount = 100;          // 共享票数 
    private static Object obj = new Object();      // 唯一的锁 
 
    @Override 
    public void run() {
        while (true) {
            synchronized (obj) {                  // 所有线程共用 obj 锁 
                if (ticketCount <= 0) {
                    break;
                } else {
                    try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }
                    ticketCount--;
                    System.out.println(getName() + " 在卖票, 还剩下 " + ticketCount + " 张票");
                }
            }
        }
    }
}

测试类:

MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
t1.setName("窗口一");
t2.setName("窗口二");
t1.start();
t2.start();

此时共享数据 ticketCount 和锁 obj 都是 static,所有线程共用同一把锁,线程安全得以保证。

项目结构

threadmodule/src/com/wb/threaddemo10/
├── MyThread.java 
└── Demo.java 

同步方法

synchronized 还可以直接修饰方法,将整个方法体变为同步代码。

1 ) 同步成员方法

  • 锁对象是 this
  • 适用于 Runnable 方式,因为多个线程共享同一个 Runnable 对象。
public class MyRunnable implements Runnable {
    private int ticketCount = 100;
 
    // 同步方法,锁对象为 this 
    private synchronized boolean sellTicket() {
        if (ticketCount == 0) {
            return true;                 // 卖完 
        } else {
            try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }
            ticketCount--;
            System.out.println(Thread.currentThread().getName()
" 在卖票, 还剩下 " + ticketCount + " 张票");
            return false;
        }
    }
 
    @Override 
    public void run() {
        while (true) {
            if ("窗口一".equals(Thread.currentThread().getName())) {
                if (sellTicket()) break;   // 调用同步方法 
            }
            // ... 窗口二可以使用同步代码块,保证同一把锁(this)
        }
    }
}

2 ) 同步静态方法

  • 锁对象是 类的字节码文件,即 类名.class
  • 静态方法中没有 this,只能使用类锁。

示例:

private static synchronized boolean synchronizedMethod() {
    // 锁对象为 MyRunnable.class 
    // 卖票逻辑 
}

在与同步代码块混用时,若需共用一把锁,则同步块应使用 MyRunnable.class。

synchronized (MyRunnable.class) {
    // 与静态同步方法使用同一把锁 
}

测试类:

MyRunnable mr = new MyRunnable();
Thread t1 = new Thread(mr, "窗口一");
Thread t2 = new Thread(mr, "窗口二");
t1.start();
t2.start();

此时同步方法和同步代码块使用的都是同一把锁(对象锁 this 或类锁 .class),线程安全。

3 ) 项目结构

threadmodule/src/com/wb/threaddemo11/
├── MyRunnable.java 
└── Demo.java 

Lock 锁

JDK 5 起提供了更灵活的锁接口 java.util.concurrent.locks.Lock
可手动控制 加锁 (lock()) 和 释放锁 (unlock())。
为避免异常导致锁无法释放,unlock() 通常放在 finally 块中。

1 ) 使用 ReentrantLock

import java.util.concurrent.locks.ReentrantLock;
 
public class Ticket implements Runnable {
    private int ticket = 100;
    private ReentrantLock lock = new ReentrantLock();
 
    @Override 
    public void run() {
        while (true) {
            try {
                lock.lock();                   // 手动加锁 
                if (ticket <= 0) {
                    break;
                } else {
                    Thread.sleep(100);
                    ticket--;
                    System.out.println(Thread.currentThread().getName()
" 在卖票, 还剩下 " + ticket + " 张票");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();                // 手动释放,务必执行 
            }
        }
    }
}

测试类与同步代码块方式相同,运行效果一致,线程安全。

2 ) 与 synchronized 对比

特性synchronizedLock
加锁/释放自动手动 (lock() / unlock())
灵活性较低可中断、可尝试获取锁
异常处理自动释放必须在 finally 中释放
出现版本Java 早期JDK 5

3 ) 项目结构

threadmodule/src/com/wb/threaddemo12/
├── Ticket.java 
└── Demo.java 

死锁

当多个线程相互持有对方所需的锁时,会产生 死锁,所有线程永久阻塞。

1 ) 死锁产生的条件

  • 有多个锁(如 A 锁、B 锁)。
  • 线程1 先获取 A 锁,再试图获取 B 锁;线程2 先获取 B 锁,再试图获取 A 锁。

等待

等待

线程1 持有 A 锁

B锁

线程2 持有 B 锁

A锁

2 ) 死锁代码演示

public class DeadLockDemo {
    public static void main(String[] args) {
        Object objA = new Object();
        Object objB = new Object();
 
        new Thread(() -> {
            while (true) {
                synchronized (objA) {
                    synchronized (objB) {
                        System.out.println("线程1 正在运行");
                    }
                }
            }
        }).start();
 
        new Thread(() -> {
            while (true) {
                synchronized (objB) {
                    synchronized (objA) {
                        System.out.println("线程2 正在运行");
                    }
                }
            }
        }).start();
    }
}

运行后程序将卡死,两个线程都无法继续执行。

3 ) 避免死锁

  • 尽量减少锁的嵌套
  • 如果需要多个锁,确保所有线程 按相同的顺序 获取锁。

4 ) 项目结构

threadmodule/src/com/wb/threaddemo13/
└── Demo.java 

总结

  1. 线程安全问题源于多个线程并发操作共享数据。
  2. 解决方式:将操作共享数据的代码变为 原子操作,即加锁。
  3. Java 提供了:
    • synchronized 同步代码块(灵活指定锁对象)
    • synchronized 同步方法(成员方法锁 this,静态方法锁 类.class
    • Lock 接口(ReentrantLock)实现手动控制锁
  4. 锁对象的 唯一性 是线程安全的前提。
  5. 死锁由 锁嵌套 导致,开发中应避免。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Wang's Blog

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值