【超详细】深入探究Java中的线程安全,让你的程序更加可靠~

深入探究Java中的线程安全,让你的程序更加可靠!

我们将从以下四个问题入手,对Java的多线程问题抽丝剥茧。

  1. 什么是线程安全?
  2. 如何实现线程安全?
  3. 不同的线程安全实现方法有什么区别?
  4. 如何实现HashMap线程安全?

1. 什么是线程安全?


线程安全指的是多个线程并发访问共享资源时,不会出现数据不一致或其他意外情况的情况。在多线程编程中,线程安全非常重要,因为多个线程可能会同时访问和修改同一数据,如果不进行适当的同步处理,就可能导致数据不一致、竞态条件和死锁等问题。

为了实现线程安全,需要使用一些技术和方法来保证数据的一致性和同步性,例如锁机制、原子操作、线程局部变量等。常用的线程安全类包括Vector、CopyOnWriteArrayList、Hashtable、ConcurrentHashMap、原子类等。

2. 如何实现线程安全?


在Java中,线程安全可以通过以下几种方式实现:

  1. synchronized关键字:读作“森科奈日得”。Java中最基本的锁机制。使用synchronized关键字可以保证多个线程访问共享资源时的互斥性,确保同一时刻只有一个线程能够访问共享资源。可以用于方法或代码块。当方法或代码块被synchronized关键字修饰时,只有一个线程能够进入该方法或代码块,其他线程则会被阻塞,直到当前线程执行完毕。

synchronized的实现原理:被synchronized修饰的代码块称为同步块,当线程进入同步块时,会尝试获取对象的锁,如果对象没有被加锁或者已经获取了该对象的锁,则锁计数器+1;如果该对象已经被其他线程加锁,则该线程会进入阻塞状态,等待其他线程释放锁。当其他线程释放锁后,等待的线程会被唤醒,并重新尝试获取锁并执行同步块中的代码。同一时刻,只有一个线程可以获取对象的锁并执行同步块中的代码。

对象头:在Java中,每个对象都有一个对象头(Object Header),它用于存储对象的元数据,包括对象的哈希码(hashCode)、锁状态、GC标记状态等信息。对象头的大小是固定的,通常占用8个字节(64位系统)或4个字节(32位系统)。
对象头中最重要的信息是锁状态,用于实现Java中的synchronized关键字的同步机制。锁状态的值可以是无锁状态、偏向锁状态、轻量级锁状态或重量级锁状态,锁状态取决于线程之间的竞争情况和锁的使用方式。

以下方法为synchronized关键字的使用:

public class Counter {
    
    
    private int count;

    // synchronized 修饰方法
    public synchronized void increment() {
    
    
        count++;
    }

    // synchronized 修饰代码块
    public void add(int n) {
    
    
        synchronized (this) {
    
    
            count += n;
        }
    }
}

使用synchronized关键字的注意点:

  • 方法是实例方法(非静态方法)

优点:简单易用、支持可重用锁。
缺点:性能问题、只能保护代码块或方法。

  1. volatile关键字:读作“我你太欧”。只能用于修饰变量。使用volatile关键字可以保证变量的可见性,即使多个线程同时访问同一个变量时,保证变量的值是一致的。此外,volatile还具有禁止指令重排的作用。当一个变量被volatile修饰时,所有线程访问该变量都是从主内存中读取最新的值。

可见性:如果两个线程同时对一个volatile变量进行修改,由于volatile变量能够保证可见性,那么它们的修改结果都会被立即刷新到主内存中,从而使得另外一个线程可以读取到最新的值。

读取操作顺序与写入操作顺序一致:如果一个线程读取了volatile变量,而在它进行写入之前,另一个线程也读取了同一个volatile变量,那么在第一个线程写入变量之后,另一个线程读取到的变量值是第一个线程写入的最新值,而不是读取时的值。

指令重排:是指处理器或编译器为了优化程序执行效率,在不改变原有程序执行结果的前提下,改变指令的执行顺序,以达到减少指令执行的等待时间、利用处理器的多级流水线、减少分支预测错误等目的。在单线程环境下,指令重排不会带来任何问题,因为最终执行结果不会发生变化。但在多线程环境下,指令重排可能会导致一些意料之外的结果,例如数据不一致、死锁、无限循环等问题。

以下方法为volatile关键字的使用:

public class VolatileExample {
    
    
    private volatile int count = 0;

    public void increment() {
    
    
        count++;
    }

    public int getCount() {
    
    
        return count;
    }
}

优点:变量对于所有线程的可见性、顺序读写
缺点:不能保证原子性、频繁读写volatile变量的开销大

  1. Lock锁:使用Lock锁机制可以实现更加灵活的锁操作,比synchronized关键字更加高效。Lock锁最常用的是可重入锁ReentrantLock,Reentrant读作“瑞恩穿特”。

可重入锁是指可以对同一个锁进行重复的加锁和解锁操作,每次加锁操作都必须对应一个解锁操作,否则锁将一直被占用。synchronized关键字也是可重入锁。

以下方法为ReentrantLock可重入锁的使用:

import java.util.concurrent.locks.ReentrantLock;

public class Demo {
    
    
    private ReentrantLock lock = new ReentrantLock();

    public void method1() {
    
    
        lock.lock();
        try {
    
    
            System.out.println("method1");
            method2();
        } finally {
    
    
            lock.unlock();
        }
    }

    public void method2() {
    
    
        lock.lock();
        try {
    
    
            System.out.println("method2");
        } finally {
    
    
            lock.unlock();
        }
    }
}


可重入锁的特点:

  • 支持重复加锁。在同一个线程中,可重入锁可以对同一个锁进行多次加锁,而不会出现死锁的情况。

重复加锁:内部有一个类似于计数器的变量(锁计数器),每当加锁时,计数器+1,解锁时,计数器-1,直到计数器为0时释放锁。

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockDemo {
    
    
    private ReentrantLock lock = new ReentrantLock();

    public void foo() {
    
    
        lock.lock(); // 第一次加锁
        System.out.println(Thread.currentThread().getName() + " get lock.");
        lock.lock(); // 第二次加锁
        System.out.println(Thread.currentThread().getName() + " get lock again.");
        lock.unlock(); // 第一次释放锁
        System.out.println(Thread.currentThread().getName() + " release lock.");
        lock.unlock(); // 第二次释放锁
        System.out.println(Thread.currentThread().getName() + " release lock again.");
    }

    public static void main(String[] args) {
    
    
        ReentrantLockDemo demo = new ReentrantLockDemo();

        Thread t1 = new Thread(() -> {
    
    
            demo.foo();
        }, "Thread-1");

        Thread t2 = new Thread(() -> {
    
    
            demo.foo();
        }, "Thread-2");

        t1.start();
        t2.start();
    }
}

//输出结果
//Thread-1 get lock.
//Thread-1 get lock again.
//Thread-1 release lock.
//Thread-1 release lock again.
//Thread-2 get lock.
//Thread-2 get lock again.
//Thread-2 release lock.
//Thread-2 release lock again.

  • 支持公平锁和非公平锁。可重入锁可以指定是公平锁还是非公平锁,默认是非公平锁。

公平锁和非公平锁:公平锁是指多个线程在等待锁时,按照等待的时间先后依次获得锁,即先到先得的策略。非公平锁则是不考虑等待的时间先后,直接去抢占锁,这可能会导致某些线程一直无法获得锁。

  • 支持中断响应。可重入锁允许在等待锁的过程中响应中断。

  • 支持多条件变量。可重入锁可以为每个条件变量创建一个等待队列,并且可以在条件变量上等待或者唤醒指定数量的线程。

优点:可以重复获取锁避免死锁,性能好,可扩展(公平锁非公平锁、可重入读写锁等)
缺点:代码复杂

  1. 原子操作:原子操作是一种无需加锁的线程安全操作方式,可以保证多个线程同时访问同一个变量时,仍能保证数据的一致性。Java中通过使用原子操作类中的原子操作方法,实现线程安全。

优点:保证操作的完整性,不需要加锁,保证数据的一致性和可见性
缺点:不能保证并发访问的顺序,不能保证数据的原子性(如果操作的数据比较大,仍然需要加锁来保证原子性),实现比较复杂

  1. 线程安全的集合类:Java提供了一些线程安全的集合类,例如Vector、CopyOnWriteArrayList、Hashtable、ConcurrentHashMap等。

Vector:可以理解为线程安全的ArrayList,提供的方法与ArrayList相似,使用synchronized关键字实现。比较古老,可以类比HashTable和HashMap的关系。
CopyOnWriteArrayList:线程安全的ArrayList,将原有的数组复制一份,然后在新数组上进行修改操作,最后再赋给原的数组引用。相对Vector更加高效。
原子操作类:包括AtomicInteger、AtomicLong、AtomicBoolean在内7种。

需要针对具体情况选择合适的线程安全技术和方法,保证多个线程能够正确、高效地访问共享资源。

3. 不同的线程安全实现方法有什么区别?


不同的线程安全实现方法有不同的适用场景和性能表现。比如,synchronized关键字适用于对临界区进行加锁,可以保证线程安全,但是性能可能会受到影响;而使用ConcurrentHashMap等线程安全的集合类,则可以在高并发情况下提高性能和并发性能。

粒度 性能 使用难易
synchronized 修饰方法或代码块,作用对象是整个类或者整个方法、类中的某个成员变量或者代码块 性能较差,不适合高并发场景。 只需在需要同步的方法或代码块前加上synchronized关键字
volatile 修饰变量,作用对象是变量 频繁读写时开销大 只需要在变量前加上volatile关键字
ReentrantLock可重入锁 作用对象是某个变量或者某个代码块 性能较好,适合高并发场景。 需要自己手动加锁和释放锁
原子操作 作用对象是某个变量或者某个代码块 相对较低 实现比较复杂,需要对硬件平台和操作系统进行深入了解,对开发人员的要求比较高

4. 如何实现HashMap线程安全?


  1. HashMap和Hashtable
区别 HashMap Hashtable
线程安全性 不安全,需要进行额外的同步操作 安全
null值 允许key和value都为null 不允许
初始容量和扩容机制 默认初始容量为16,扩容机制是元素数量大于负载因子和数组长度的乘积时,将数组长度翻倍 默认初始容量为11,扩容机制是元素数量大于数组长度时,将数组长度翻倍再加1
遍历方式 通过Iterator实现 通过Enumeration实现
  1. HashTable的常见问题:
    HashTable线程安全而HashMap线程不安全:Hashtable采用了同步机制来保证线程安全,即在每个公共方法上使用了synchronized关键字来确保同一时间只能有一个线程操作Hashtable。而HashMap则没有采用这种同步机制。

HashTable公共方法源码:

// 判断Hashtable中是否存在某个value
public synchronized boolean contains(Object value) {
    
    
    if (value == null) {
    
    
        throw new NullPointerException();
    }

    Entry<?,?> tab[] = table;
    for (int i = tab.length ; i-- > 0 ;) {
    
    
        for (Entry<?,?> e = tab[i] ; e != null ; e = e.next) {
    
    
            if (e.value.equals(value)) {
    
    
                return true;
            }
        }
    }
    return false;
}

HashTable的key和value不允许为null:对于 Hashtable,插入元素时,如果该桶中已经存在元素,则通过 equals方法比较新插入的 key 和桶中已经存在的 key 是否相等,在比较 key 是否相等时需要调用 key 的 equals 方法,如果key为null则没有equals方法。Hashtable的值不允许为null,是因为在Hashtable内部,值被存储在一个Object类型的数组中,数组中的每个元素是一个单独的值对象,而不是一个值的引用。因此,如果允许值为null,将导致无法区分数组中的空槽和实际存储了null值的槽。这可能会导致在对Hashtable进行操作时出现意外的结果。为了避免这种情况,Hashtable不允许null值。

HashMap的key和value允许为null:HashMap的设计目标是尽可能提供高效的查找、插入和删除操作,因此对值的类型没有限制。这样可以让使用者在需要时自由地将null作为值来使用,增加了灵活性。在HashMap中,如果key为null,则它的哈希值为0,因此会将其放在哈希表的第0个位置。

  1. 实现HashMap的线程安全
  • 使用 ConcurrentHashMap
    这是Java提供的线程安全的HashMap实现。它通过分段锁(Segment)的方式来实现线程安全,多个线程可以同时访问不同的Segment,从而提高并发度。

ConcurrentHashMap与HashTable的区别:Hashtable 是基于 synchronized 实现线程安全, ConcurrentHashMap 使用了分段锁的方式来实现线程安全。如果需要在多线程环境下使用哈希表,推荐使用 ConcurrentHashMap。

  • 使用 Collections.synchronizedMap
    这是Java提供的一个工具类,用于将一个非线程安全的Map包装成一个线程安全的Map。它通过对Map的操作加上同步锁(使用synchronized关键字)的方式来实现线程安全。

使用Collections.synchronizedMap实现线程安全代码示例:

Map<String, String> synchronizedMap = Collections.synchronizedMap(new HashMap<>());

synchronizedMap.put("key1", "value1");
synchronizedMap.put("key2", "value2");

String value = synchronizedMap.get("key1");
System.out.println(value);

  • 使用锁机制
    可以自己实现锁机制来保证HashMap的线程安全。比如可以使用synchronized关键字或者ReentrantLock来实现锁机制,保证同一时刻只有一个线程访问HashMap。

在多线程环境下,推荐使用ConcurrentHashMap,因为它的并发度更高,性能更优,但需要注意一些细节问题,如在遍历时需要使用迭代器等。而如果是简单的线程安全需求,可以考虑使用Collections.synchronizedMap。

猜你喜欢

转载自blog.csdn.net/m0_56170277/article/details/129786479