什么是线程不安全?

多线程环境下对同一份数据的访问是否能够保证其正确性和一致性。

安全代表着无论多少格线程同时访问一份数据,都不会出现数据混乱、错误或者丢失等问题。

这个问题通常对公共资源加锁来解决。(Synchronized/ReentrantLock/JUC包中其他工具类)

下面就是一个线程不安全的示例,有两个线程都对 count(公共资源) 进行自增1000次。

结果应该是2000,但是运行结果<2000,因为这两个线程可能在count = 88 的时候同时获取到了它,然后并对其进行操作。这样就相当于产生了一次重复操作,少了一次自增。

public class UnsafeCounter {

    private static Integer count = 0;

    public static void increment() {
        count++;
    }

    public static void main(String[] args) {
        Object o = new Object();

        // 创建多个线程来增加计数器的值
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                UnsafeCounter.increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                UnsafeCounter.increment();
            }
        });

        // 启动线程
        thread1.start();
        thread2.start();

        // 等待线程结束
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 输出最终计数器的值
        System.out.println("计数结果: " + UnsafeCounter.count);
    }
}

Synchronized 锁

Synchronized锁是一种用于控制对共享资源的访问、保证线程安全的机制。

它通过确保任何时候只有一个线程能够执行某个对象的同步代码块或方法,从而避免了多个线程同时访问共享资源可能引发的数据不一致问题。

锁的作用

互斥:确保在同一时刻只有一个线程可以执行同步代码块或方法。

可见性:保证一个线程对共享变量所做的修改,在另一个线程进入同步代码块或方法之前一定是可见的。这是由于Java内存模型规定了synchronized具有释放锁之前刷新缓存到主存以及获取锁之后从主存更新缓存的效果。

注意:

尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能

构造方法不能使用 synchronized 关键字修饰,因为构造方法本身就属于线程安全

Synchronized的使用主要有下面三种方式,需要注意锁的对象是谁。

// Synchronized代码块,可以锁任意对象
synchronized(this(当前对象)/任意对象/.class也行) {
    // 需要同步的代码块
}

//  Synchronized方法,方法上加关键字,获取当前方法所在对象的锁,等同于synchronized(this)
public synchronized void add() {
    // 需要同步的代码块
}

// Synchronized静态方法,获取当前方法所在类的字节码锁(.class),等同于synchronized(xxx.class)
public static synchronized void add() {
    // 需要同步的代码块
}

锁的作用

互斥:确保在同一时刻只有一个线程可以执行同步代码块或方法。

可见性:保证一个线程对共享变量所做的修改,在另一个线程进入同步代码块或方法之前一定是可见的。这是由于Java内存模型规定了synchronized具有释放锁之前刷新缓存到主存以及获取锁之后从主存更新缓存的效果。

ReentrantLock 锁

ReentrantLock 是JUC包中提供的一个重入锁,需要手动释放(finally保证),提供了很多高级更能(公平锁、非公平锁、Condition分组唤醒、中断等待)。

重入特性

重入这种特性是为了防止一个线程自己被在自己锁持有的锁阻塞,从而导致死锁。

下面有一个简单的示例

调用methodA()在不是重入锁的情况下就会导致死锁。

原因是一个线程调用A时会获取lock这把锁,然后方法A调用了方法B,方法B又去获取lock这把锁就会陷入等待A释放锁,但A又没有执行完毕,最终陷入循环等待,导致死锁。

但是我们使用的是ReentrantLock ,有重入特性就不会发生这种问题。

public class Example {
    private final ReentrantLock lock = new ReentrantLock();

    // 方法A
    public void methodA() {
        lock.lock();
        try {
            // 调用方法B
            methodB();
        } finally {
            lock.unlock();
        }
    }

    // 方法B
    public void methodB() {
        lock.lock();
        try {
            // Do something else
        } finally {
            lock.unlock();
        }
    }
}

重入特性是通过维护一个个获取计数来实现的,每次获取锁时,计数加一(lock.lock();),每次释放减一(lock.unlock();),计数归零锁才真正被释放。

公平选择

默认情况下,ReentrantLock 是非公平的。ReentrantLock 构造函数允许您创建一个公平锁或非公平锁。

// 传入一个 boolean 值,true 时为公平锁,false 时为非公平锁
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

Condition条件唤醒

Condition用于替代wait()和notify()方法,优点在于notify只能随机唤醒等待的线程,而Condition可以唤醒指定的线程。

使用方法:通过lock.newCondition();创建Condition对象

主要方法

  • condition.await() 使线程进入等待

  • condition.signalAll() 唤醒condition上所有等待的线程

  • condition.signal() 唤醒condition上某一个等待的线程,(随机选择一个)

都是锁的范围内使用,还可以创建多个Condition对线程进行分组。

public class ConditionSample {

    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
//  创建组2
//    private final Condition condition2 = lock.newCondition();
    private boolean flag = false;


    public static void main(String[] args) {
        ConditionSample sample = new ConditionSample();

        // 创建一个等待线程
        Thread waitingThread = new Thread(() -> {
            sample.await();
        }, "WaitingThread");

        // 创建一个通知线程
        Thread signalingThread = new Thread(() -> {
            try {
                // 模拟一些操作
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            sample.signal();
        }, "SignalingThread");

        waitingThread.start();
        signalingThread.start();
    }

    public void await() {
        lock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + " 开始判断...");
            // 等待条件满足
            while (!flag) {
                System.out.println(Thread.currentThread().getName() + " 等待条件满足...");
                condition.await();
            }
            System.out.println(Thread.currentThread().getName() + " 条件已满足,继续执行...");
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock();
        }
    }

    public void signal() {
        lock.lock();
        try {
            // 设置条件为true,并通知等待的线程
            flag = true;
            System.out.println(Thread.currentThread().getName() + " 条件已设置,通知等待的线程...");
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }


}

加锁后产生的问题

死锁,什么是死锁?

多个线程同时被阻塞,它们中一个或全部都在等待某个资源释放,由于线程被无限期的阻塞,导致程序无法正常终止。

下面的案列就是死锁,两个线程运行到sleep(1000)的时候,线程1持有A资源,线程2持有B资源,它们申请资源都被对方持有,所以都陷入了阻塞状态。

阻塞状态并不会结束线程,所以资源没有被释放,导致它们互相在等待对方执行完毕释放资源,这就进入了死锁状态。

public class DeadLock {
    private static final Object fileA = new Object();
    private static final Object fileB = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            // 请求文件A
            synchronized (fileA) {
                System.out.println("线程1:请求文件A");
                // 停顿,保证冲突
                try { Thread.sleep(1000);} catch (Exception e) {throw new RuntimeException(e);}
                // 请求文件B
                synchronized (fileB) {
                    System.out.println("线程1:请求文件B");
                }
            }
        }).start();

        new Thread(() -> {
            // 请求文件B
            synchronized (fileB) {
                System.out.println("线程2:请求文件B");
                try { Thread.sleep(1000);} catch (Exception e) {throw new RuntimeException(e);}
                // 请求文件A
                synchronized (fileA) {
                    System.out.println("线程2:请求文件A");
                }
            }
        }).start();

    }
}

死锁的四个必要条件

  1. 互斥:该资源任意时刻只由一个线程占用

  2. 请求与保持:一个线程因请求资源而阻塞时,对已获得的资源保持不放

  3. 不剥夺:线程已获得的资源在未使用完之前,不能被其他线程强行剥夺,只有自己使用完毕后才会的都释放

  4. 循环等待:若干线程之间,形成一种头尾相接的循环等待资源关系

如何预防和避免死锁?

  • 破坏形成死锁的必要条件即可预防死锁的发生。

  • 破坏请求与保持:一次性申请所有的资源

  • 破坏不剥夺条件:占用部分资源的线程,申请其他资源,如果申请不到,可以主动释放持有资源

  • 破坏循环等待条件:按照顺序申请资源,释放资源则按反顺序释放

在之前线程1持有A,线程2持有B,它们同时申请对方资源导致死锁的问题。

就可以让线程1与线程2都持有AB(一次性申请所有的资源),这样执行过程中一定有一方拿不到AB资源从而进入等待。

而另一方拿到了AB资源可以正常执行,然后释放资源。

或则按照相同的顺序申请资源,比如颠倒线程2的资源获取顺序,让它保持和线程1的顺序一致,这样它们就会去争抢同一个资源。

线程安全的集合

我们常使用的ArrayList、LinkedList、HashSet、TreeSet、StringBuilder、HashMap等都是线程不安全的,它们的执行速度比线程安全的类快,在非多线程环境下使用是非常合适的。

多线程环境下,最早期Java1.0版本有Vector(对应List),HashTable(对应HashMap)是线程安全的,现在已经不推荐使用了,它们的性能比较低。

StringBuffer是线程安全的,仍旧在使用。

在Java1.5之后,引入了java.util.concurrent(JUC)包,提供了性能更好的线程安全类(List,Set,Map)。

线程不安全

线程安全

ArrayList

CopyOnWriteArrayList

HashSet

CopyOnWriteArraySet

HashMap

ConcurrentHashMap

CopyOnWriteArrayList 原理

它的源码中首先对底层数组添加了volatile关键字,然后对增删改操作加锁,然后拷贝数组一份(副本),在副本上进行对应操作,之后将底层数组变量的引用指向这个新的数组。

也就是说,它对写操作进行加锁和拷贝副本,读操作没有加锁。

都加锁了为什么要拷贝副本?

不拷贝副本,写操作加锁,读操作没加锁的情况下,要是正在写入的时候被读取就会有不可预料的问题,比如ArrayList 在 for-each 循环中删除元素时,会抛出 ConcurrentModificationException,这是因为 ArrayList 的迭代器(Iterator)在遍历时会检查集合是否被修改,检测到修改就会抛出该异常,这个机制其实是为了保障并发读写的安全性。

如果不拷贝副本,写操作加锁,读操作也加锁,那么就没有问题,但是性能会极具下降,都争抢同一把锁,性能肯定高不了。

而复制副本就可以很好的解决这些问题。

首先,写操作加锁保障了安全性。

因为有复制副本,让写操作转移到副本上执行,所以在写入的同时,读操作会执行到旧数据上,这一步保证了读操作的执行效率。

写入完成后会修改底层数组变量的引用指向这个副本,因为有 volatile 关键字修饰,让这一步修改操作会被其他线程看到,保障了新数组的线程可见性。

简单来说,就是增删改操作会将底层数组拷贝一份,更新操作在新数组上执行,这时不影响其他线程并发读,实现了读写分离。

副本的缺陷:

对内存占用增大,因为有复制的操作,所以占用两份内存,需要等待垃圾回收来清理旧数据,如果数组很大,又进行了复制,可能会频繁的引起垃GC,降低性能。

这种方案只能保证数据最终的一致性,在写入的同时,读操作是读取的旧数据,也就是说会产生瞬时的数据不一致。

CopyOnWriteArraySet原理

和CopyOnWriteArrayList差不多

ConcurrentHashMap原理

Hashtable 简单来说就是加了锁,所有数据操作需要等待前面一个操作执行完毕,资源释放后才能执行。

而 ConcurrentHashMap 采用了分段锁的方式进行了优化,根据算法将数据分段,每一个段持有一把锁,访问同一个段的数据会产生阻塞,如果访问不同段的数据,不会产生阻塞,就可以达到并发的效果。

CountDownLatch 倒计时锁

CountDownLatch 是一个同步工具类,它允许一个或多个线程等待其他线程完成操作后再继续执行。

初始化:CountDownLatch latch = new CountDownLatch(int count) 实例化并设置计数值

主要方法

  • latch.countDown() 让计数值-1

  • latch.await() 让当前线程阻塞,当计数值减少到0时,会唤醒当前线程

下面有一个简单示例

public class CountDownLatchExample {

    public static void main(String[] args) throws InterruptedException {
        // 创建一个 CountDownLatch,初始计数为 3
        int threadCount = 3;
        CountDownLatch latch = new CountDownLatch(threadCount);

        // 创建并启动多个线程
        for (int i = 1; i <= threadCount; i++) {
            new Thread(() -> {
                try {
                    System.out.println(Thread.currentThread().getName() + " 正在执行任务...");
                    // 模拟任务执行时间
                    Thread.sleep((long) (Math.random() * 2000));
                    System.out.println(Thread.currentThread().getName() + " 任务完成!");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    // 任务完成后,计数减 1
                    latch.countDown();
                }
            }).start();
        }

        System.out.println("主线程等待所有子线程完成任务...");

        // 主线程等待所有子线程完成任务,直到计数减为 0,就会唤醒
        latch.await();
        System.out.println("所有子线程已完成任务,主线程继续执行...");
    }
}

Semaphore 信号量

Semaphore 是一个同步工具类,用于控制同时访问某个资源的线程数量。它通过维护一组许可证来实现资源的访问控制。

初始化:Semaphore semaphore = new Semaphore(int permits); 设置许可证数量

许可证数量决定了同时访问资源的线程数量。如果许可证数量为 1,Semaphore 相当于一个互斥锁。

主要方法

semaphore.acquire(); 获取许可证,是阻塞方法,如果许可证不足,线程会等待。

semaphore.tryAcquire() 获取许可证,是非阻塞方法,如果许可证不足,线程会立即返回 false

semaphore.release(); 释放许可证

下面有一个简单示例

public class SimpleSemaphoreExample {
    public static void main(String[] args) {
        // 允许 2 个线程同时访问
        Semaphore semaphore = new Semaphore(2); 

        for (int i = 1; i <= 10; i++) {
            new Thread(() -> {
                try {
                    semaphore.acquire(); // 获取许可证
                    System.out.println(Thread.currentThread().getName() + " 正在执行任务...");
                    Thread.sleep(2000); // 模拟任务执行
                    System.out.println(Thread.currentThread().getName() + " 任务完成!");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    semaphore.release(); // 释放许可证
                }
            }, "Thread-" + i).start();
        }
    }
}

CyclicBarrier循环屏障

CyclicBarrier 是一个同步工具类,它允许一组线程互相等待,直到所有线程都到达某个屏障点后再继续执行。

int partie 需要相互等待的线程数量

Runnable barrierAction 这个一个实现了Runnable接口的线程类,里面的run方法就是都需要同步的线程都到达屏障点后要执行的方法

初始化:CyclicBarrier barrier = new CyclicBarrier(int parties, Runnable barrierAction)

主要方法

barrier.await() 设置屏障点,其作用是让当前线程进入阻塞状态,等待其他线程执行到同步点。

下面有一个简单示例

public class SimpleCyclicBarrierExample {
    public static void main(String[] args) {
        int threadCount = 3;
        CyclicBarrier barrier = new CyclicBarrier(threadCount, () ->
                System.out.println("所有线程已到达屏障,继续执行!")
        );

        for (int i = 1; i <= threadCount; i++) {
            new Thread(() -> {
                try {
                    System.out.println(Thread.currentThread().getName() + " 正在执行任务...");
                    Thread.sleep((long) (Math.random() * 2000)); // 模拟任务执行
                    System.out.println(Thread.currentThread().getName() + " 任务完成,等待其他线程...");
                    barrier.await(); // 等待其他线程
                    System.out.println(Thread.currentThread().getName() + " 继续执行后续操作...");
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }, "Thread-" + i).start();
        }
    }
}

Atomic包

Atomic包是JUC下的一个为线程安全设计的Java包,里面包含多个原子操作类。

比如 AtomicInteger、AtomicIntegerArray、AtomicBoolean、AtomicLong等

下面是一个AtomicInteger的简单示例

public class AtomicExample {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger counter = new AtomicInteger(0); // 初始化原子计数器

        // 每个线程对count自增1000次
        Runnable task = () -> {
            for (int i = 0; i < 1000; i++) {
                // 原子自增 效果等于int类型的 count++;但这个count++是线程不安全的
                counter.incrementAndGet(); 
            }
        };
        // 创建多个线程
        Thread thread1 = new Thread(task);
        Thread thread2 = new Thread(task);
        Thread thread3 = new Thread(task);

        thread1.start();
        thread2.start();
        thread3.start();

        // 等待所有线程执行完成
        thread1.join();
        thread2.join();
        thread3.join();

        System.out.println("最终计数器的值: " + counter.get());
    }
}