【Java 多线程】5 - 3 线程安全与线程锁

§5-3 线程安全与线程锁

5-3.1 线程的安全问题

若多线程环境下代码执行的结果是符合预期的,则说明该程序是线程安全的。否则,在多线程环境下代码执行结果不符合预期的程序则是线程不安全的。

导致线程不安全的因素有很多,可能是由于多个线程同时访问和修改共享的资源或数据。下面简单列出部分原因:

  • 抢占式执行:抢占式调度造成线程执行顺序不确定,而不同的执行顺序可能会造成意料之外的结果,导致线程安全问题;

  • 指令重排序:编译器会对代码做优化,而优化的本质就是重新排序指令,这在多线程编程中可能引发一些并发问题;

  • 操作不具备原子性:操作的原子性指的是一个操作或一系列操作要么全部执行,要么全部不执行,中间不能够被中断,即原子操作不可被分割。然而,一条 Java 语句并不一定具备原子性。

    以自增操作为例,自增操作由三步构成:从内存中读取数据到寄存器中,计算(更新数据)、将新数据写回内存中。

    多条线程同时操作同一个变量时,由于线程的执行时长不同,就可能会发生操作未完成由另外一个线程打断的情况,线程对变量的修改并不能够及时地被其他线程看到,进而导致得到的结果与预期不匹配的情况。

一个可能发生线程不安全的程序示例:

public class ThreadSafeProblem {
    public static void main(String[] args) throws InterruptedException {
        //一个线程不安全的程序示例
        //某电影院正在热映一部新电影,共有 100 张票,分三个窗口售卖。设计一个程序模拟售票
        Thread t1 = new Thread(new TicketSeller(), "窗口 1");
        Thread t2 = new Thread(new TicketSeller(), "窗口 2");
        Thread t3 = new Thread(new TicketSeller(), "窗口 3");
        
        //启动线程,开始售票
        t1.start();
        t2.start();
        t3.start();
    }

    static class TicketSeller extends Thread {
        //记录总票数
        private static final int total = 100;
        //记录已售票数
        private static int ticketSold;

        //模拟售票
        @Override
        public void run() {
            while (true) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                if (ticketSold < total) {
                    ++ticketSold;
                    System.out.println(Thread.currentThread().getName() + ":已售出第 " + ticketSold + " 张票。");
                } else {
                    System.out.println(Thread.currentThread().getName() + ":已售罄。");
                    break;
                }
            }
        }
    }
}

示例程序中,多个线程同时修改静态变量 ticketSoldrun 方法中,每个线程都会检查 ticketSold 是否小于 total。是,则自增 ticketSold 并打印售出票数;否,则售票结束。

进入 if 分支后,sleep 方法会让线程进入阻塞。此时另外一条线程执行 run 方法,进入阻塞,直至所有线程全部阻塞。线程醒来时,任何一条线程都可能随时抢走 CPU 执行权,则可能会发生重复售卖同一张票、超额售票的情况。

在多线程的环境下,很有可能发生上述的线程不安全问题。为解决这一问题,可以对对象上锁。

5-3.2 synchronized 同步

使用 synchronized 关键字,实现线程同步。

5-3.2.1 同步代码块

同步代码块把操作共享的数据的代码锁起来。

synchronized (lock) {
    //Code
}

注意

  • 同步代码块的锁默认打开,当有线程进入时,锁自动关闭,直至锁中全部代码执行完毕,线程出来,锁就会自动打开;

  • 锁关闭期间其他线程都无法执行其中的代码,只能等待锁打开;

  • lock 是一个锁对象,锁对象任意,但必须唯一,通常以当前类的字节码文件为锁对象;

    若锁不唯一,则意味着多条线程可同时进入代码块内执行,锁就失去了意义;

    在同一文件夹下,每个类都是唯一的,可以考虑使用当前类作为锁对象;

  • 应当注意同步代码块的范围,过小容易造成死锁,过大会降低并发程度;

使用同步代码块解决示例程序的不安全问题

//仅展示修改部分
@Override
public void run() {
    while (true) {
        //一般考虑采用当前文件夹下的当前类作为锁对象(唯一)
        synchronized (SyncBlock.class) {
            if (ticketSold < total) {
                try {
                    Thread.sleep(10);
                    //方法阻塞,引发新一轮抢夺,发生重复售票和超额售票的情况
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                ++ticketSold;
                System.out.println(Thread.currentThread().getName() + ":已售出第 " + ticketSold + " 张票。");
            } else {
                System.out.println(Thread.currentThread().getName() + ":已售罄。");
                break;
            }
        }
    }
}

注意同步代码块的范围,若将代码块范围扩展到循环外,则会导致只有一个窗口在售票,并且该窗口会将票全部售出才会退出。

5-3.2.2 同步方法

同步方法即在方法签名处使用修饰符 synchronized 修饰方法。

public synchronized void method() {}

同步方法也会给线程上锁,但其锁对象不再是任意的,而是只有两种情况:

  • 非静态方法的同步锁为当前对象实例本身 this
  • 静态方法的同步锁为当前静态方法所在类的 Class 对象 XXX.class,这与 syncrhonized (class) 一样,都是给类上锁;

为了使得锁有意义,使用第一种方法创建的静态方法的调用者对象也应当唯一。

使用同步方法解决示例程序中的不安全问题

//重构 TicketSeller 类,成为一个独立类
public class TicketSeller implements Runnable {
    //记录总票数
    private static final int total = 100;
    //记录已售票数
    private int ticketSold = 0;

    //模拟售票
    @Override
    public void run() {
        //1. 循环
        while (true) {
            //2. 同步方法:将同步代码块转写成同步方法
            if (sell()) break;
        }
    }

    //也会给进程上锁,应当尽可能避免死锁
    private synchronized boolean sell() {
        if (ticketSold < total) {
            //3. 判断共享数据是否到了末尾:尚未到达
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            ++ticketSold;
            System.out.println(Thread.currentThread().getName() + ":已售出第 " + ticketSold + " 张票。");
        } else {
            //4. 判断共享数据是否到了末尾:已经到达
            System.out.println(Thread.currentThread().getName() + ":已售罄。");
            return true;
        }
        return false;
    }
}
//主方法
public class SyncMethod {
    public static void main(String[] args) throws InterruptedException {
        //使用同步方法解决问题
        //某电影院正在热映一部新电影,共有 100 张票,分三个窗口售卖。设计一个程序模拟售票

        //对象只创建一次
        TicketSeller ts = new TicketSeller();

        Thread t1 = new Thread(ts, "窗口 1");
        Thread t2 = new Thread(ts, "窗口 2");
        Thread t3 = new Thread(ts, "窗口 3");

        //启动线程,开启售票
        t1.start();
        t2.start();
        t3.start();
    }
}

5-3.2.3 线程同步对效率的影响

使用 synchronized 关键字,可以解决多线程同时操作同一变量可能引发的线程不安全问题。下面简单地阐述同步对效率的影响。

不同线程仍然是通过抢占式调度的方式获取 CPU 的执行权,执行顺序、执行时间随机。第一个线程触发了同步锁,但同步代码尚未完全执行完毕时就切换到了下一线程。这时,对于同一段同步代码,由于上一线程未执行完毕,同步锁未解开,该线程被阻塞。直到触发同步锁的线程中的代码执行完毕后才会解锁,由下一次抢占到 CPU 的线程触发同步锁,重复上述过程,直至所有线程都完全执行完毕并退出为止。

这一过程发生多次线程阻塞,会降低执行效率,减缓执行速度。线程同步但是在牺牲速度的前提下保证了线程安全。

5-3.3 LockReentrantLock

使用同步代码块和同步方法时,上锁与解锁是自动进行的,我们并不能看清具体的上锁解锁时机。为了更清晰地表达如何上锁、解锁,JDK 5 后提供了一个新的锁对象 Lock,可用于手动上锁、解锁。

Lock 是一个在 java.util.concurrent.locks 的接口,其实现提供了比同步代码块和方法更为广泛的锁定操作。

ReentrantLock 简介

ReentrantLock 是一个具有与 synchronized 方法和语句所访问的隐式监视器锁相同的基本行为和语义的可重入互斥锁,但拥有更多功能。

ReentrantLock 由上一个成功上锁,但未解锁的线程所有。当锁并未由其他线程占有时,调用 lock 方法的线程会成功获取锁。若当前线程已占有锁时,方法会立刻返回。锁的占有情况可由 isHeldByCurrentThread()getHoldCount() 检查。

该类的构造器接受一个可选公平性参数,若为 true,线程竞争的情况下,锁倾向于将访问权给予等待时间最长的线程(公平锁);若为 false,锁并不能保证任何特定的访问顺序。使用由多线程访问的公平锁的程序可能会降低总体的吞吐量(即更慢,通常比使用默认设置的慢得多),但获取锁和保证无饥饿现象的时间差异较小。但注意,锁的公平性并不能够保证线程调度的公平性。因此,众多使用公平锁线程中的某一线程可能会连续多次获取锁,而其他活跃线程没能持有锁,不能继续。且注意,tryLock() 方法并不遵循公平性设置,只要锁可用,即使其他线程仍在等待,调用方法也会成功获取锁。

建议总是try 语句块跟在 lock 调用语句后,最经典的是在 before/after 结构中,例如:

class X {
    private final ReentrantLock lock = new ReentrantLock();
    // ...
    
    public void m() {
        lock.lock();	// block until condition holds
        try {
            // ... method body
        } finally {
            lock.unlock();
        }
    }
}

除了实现 Lock 接口,该类还定义了许多用于检查锁状态的 publicprotected 方法,部分方法仅对仪器和监视有用。

序列化的行为表现同内置锁:反序列化所得的锁处于解锁状态,无论序列化时该锁处于何种状态。

每一把锁一旦锁上,就必须要配套一个解锁的语句。即同一把锁,锁上多少次,就要解锁多少次,这种循环嵌套的锁容易造成死锁问题(见后文)。可重入锁允许一个线程多次获取同一把锁,而通过一个持有计数器跟踪锁的持有次数,当一个线程首次获取锁时,持有计数器为 1;同一线程多次重复获取该锁,持有计数器加 1;线程释放锁时,持有计数器减 1。这种非嵌套方式,有效地避免了死锁的发生。

可重入锁支持最大 2147483647 次同一线程递归上锁,超出该上限的尝试将会抛出 Error

切记Locklock() 方法不能放在 try 中,否则,可能由于其他方法抛出异常,在 finally 代码块中就可能会对为加锁对象解锁,它会调用 AQStryRelease 方法(取决于具体实现类),抛出 IllegalMonitorStateException 异常。

方法

方法 描述
void lock() 获取锁
void lockInterruptibly() 获取锁,除非当前线程被打断
boolean tryLock()
boolean tryLock(long timeout, TimeUnit unit)
仅当调用时锁并未被其他线程持有时获取锁
void unlock() 释放锁

但是 Lock 是一个接口,这里采用它的实现类 ReentrantLock 实例化,构造方法

构造方法 描述
ReentrantLock() 创建一个可重入锁实例
ReetrantLock(boolean fair) 由指定公平性政策创建一个可重入锁实例

要注意共享数据的共享范围,从而确定锁对象的可见范围。

若多个不同共享对象的修改互不影响,那么锁可以设置为实例对象,不同的共享对象持有不同的锁。

若多个不同共享对象的修改相互影响,那么锁应当设置为类的静态成员,不同的共享对象共享一把锁。

使用 Lock 解决示例程序中的不安全问题

//重写 TicketSeller
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class TicketSeller extends Thread {
    //记录总票数
    private static final int total = 100;
    //记录已售票数
    private static int ticketSold = 0;
    //创建静态锁对象,共享锁对象,使得锁唯一
    static Lock lock = new ReentrantLock();

    //模拟售票
    @Override
    public void run() {
        while (true) {
            lock.lock();    //上锁位于循环内部第一句,避免一个窗口卖光所有的票,其他线程从阻塞恢复时可以先检查数据情况
            try {
                if (ticketSold < total) {
                    Thread.sleep(10);   //线程阻塞(休眠)
                    ++ticketSold;
                    System.out.println(Thread.currentThread().getName() + ":已售出第 " + ticketSold + " 张票。");
                } else {
                    System.out.println(Thread.currentThread().getName() + ":已售罄。");
                    break;
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                lock.unlock();  //解锁:在 finally 使得锁一定能够被释放
            }
        }
    }
}

在主方法中:

public class ManualLock {
    public static void main(String[] args) throws InterruptedException {
        //使用同步方法解决问题
        //某电影院正在热映一部新电影,共有 100 张票,分三个窗口售卖。设计一个程序模拟售票

        //创建三个窗口
        TicketSellerLck ts1 = new TicketSellerLck();
        TicketSellerLck ts2 = new TicketSellerLck();
        TicketSellerLck ts3 = new TicketSellerLck();
        ts1.setName("窗口1");
        ts2.setName("窗口2");
        ts3.setName("窗口3");

        //启动线程,开始售票
        ts1.start();
        ts2.start();
        ts3.start();
    }
}

与监视器锁相比: 从所提供的方法(功能)上来看,ReentrantLock 能提供更为灵活的锁控制,一般而言,在高并发场景下,优先考虑使用 ReentrantLock 而不是监视器锁。

5-3.4 死锁

死锁的产生条件是锁的嵌套,导致程序因为线程在互相等待锁的释放而卡死。

死锁示例:

public class DeadLockDemo {
    public static void main(String[] args) {
        //死锁示例
        DeadLocks threadA = new DeadLocks();
        DeadLocks threadB = new DeadLocks();
        threadA.setName("线程A");
        threadB.setName("线程B");

        threadA.start();
        threadB.start();
    }
}

class DeadLocks extends Thread {
    //两把锁
    static Object lockA = new Object();
    static Object lockB = new Object();

    @Override
    public void run() {
        //嵌套锁
        if ("线程A".equals(getName())) {
            synchronized (lockA) {
                System.out.println("线程A得到锁A");
                synchronized (lockB) {
                    System.out.println("线程A得到锁B");
                }
            }
        } else if ("线程B".equals(getName())) {
            synchronized (lockB) {
                System.out.println("线程B得到锁B");
                synchronized (lockA) {
                    System.out.println("线程B得到锁A");
                }
            }
        }
    }
}

运行截图:

image

两条线程都启动后,线程A先拿到A锁,但尚未拿到B锁时,线程B夺走执行权并拿到了B锁。两条线程随后不停地等待对方的锁,陷入阻塞,程序卡死,产生死锁。因此,为避免死锁,应当避免嵌套锁。

除了死锁,若两条线程互相改变对方的运行结束条件,这两条线程不会被阻塞,反而会不断地运行,这称为活锁。解决活锁问题,可以使得两条线程交替执行,或增加随机睡眠时间。

另外,线程还有一种情况是线程饥饿。一条线程由于优先级太低,长时间得不到 CPU 的调度执行,线程长时间处于等待(阻塞)状态,称为线程饥饿。

5-3.5 总结

当多个线程同时修改一个变量时,由于抢占式调度机制、指令重排序、操作不具备原子性等原因,无法保证变量的内存可见性,导致代码执行结果不如预期,这是多线程环境下线程安全问题的由来。

为了解决这一问题,Java 提供了锁机制用于解决线程安全问题。

synchronized 关键字可以用于修饰代码块和方法,默认开启锁。线程一旦执行到同步代码,则会触发同步锁。即使其他线程抢占到 CPU 的执行权,由于锁已触发,其他线程也无法执行,处于被阻塞状态。只有当触发锁的线程执行完毕后,锁才会打开,由下一个抢占到执行权的线程触发锁,重复上述过程。

synchronized 所应用的锁是监视器锁(monitor lock),其上锁和解锁的时机自动进行。

Lock 提供了手动上锁和解锁的方式,由 lockunlock 方法实现,通常使用其实现类 ReentrantLock。这种方式更为灵活,但注意上锁后一定要能够有机会开锁。

锁要求指定一个锁对象,锁对象应当唯一。同步代码块的锁对象可任意指定,但同步方法的锁对象要么为 this(单例对象,非静态方法),要么为当前类的字节码文件(XXX.class)。

使用锁时,应当注意上锁和解锁的时机,线程获得锁后,在操作数据前,应当先检查数据状况,同时也应当留意变量是否需要共享至其他线程。

volatile 关键字只能够保证变量可见性,以及禁用编译器的优化重排,但不能保证原子性,应当结合其他同步机制一起使用。

5-3.6 外部链接

Java 多线程的线程安全问题:Java多线程之线程安全问题 - 掘金 (juejin.cn)

Java 多线程(三):线程安全问题与解决办法:Java多线程(三):线程安全问题与解决方法 - 掘金 (juejin.cn)

synchronized 详解:synchronized详解 - 三分恶 - 博客园 (cnblogs.com)

javap:The javap Command (oracle.com)

并发编程4 - 线程状态、死锁及 ReentrantLock并发编程4 - 线程状态、死锁及ReentrantLock_reentrantlock会阻塞线程吗_weixin_39505091的博客-CSDN博客

Java 17 ReentrantLockReentrantLock (Java SE 17 & JDK 17) (oracle.com)

posted @ 2023-08-29 00:02  Zebt  阅读(90)  评论(0)    收藏  举报