深入探究Java中的线程安全,让你的程序更加可靠!
我们将从以下四个问题入手,对Java的多线程问题抽丝剥茧。
- 什么是线程安全?
- 如何实现线程安全?
- 不同的线程安全实现方法有什么区别?
- 如何实现HashMap线程安全?
1. 什么是线程安全?
线程安全指的是多个线程并发访问共享资源时,不会出现数据不一致或其他意外情况的情况。在多线程编程中,线程安全非常重要,因为多个线程可能会同时访问和修改同一数据,如果不进行适当的同步处理,就可能导致数据不一致、竞态条件和死锁等问题。
为了实现线程安全,需要使用一些技术和方法来保证数据的一致性和同步性,例如锁机制、原子操作、线程局部变量等。常用的线程安全类包括Vector、CopyOnWriteArrayList、Hashtable、ConcurrentHashMap、原子类等。
2. 如何实现线程安全?
在Java中,线程安全可以通过以下几种方式实现:
- synchronized关键字:读作"森科奈日得"。Java中最基本的锁机制。使用synchronized关键字可以保证多个线程访问共享资源时的互斥性,确保同一时刻只有一个线程能够访问共享资源。可以用于方法或代码块。当方法或代码块被synchronized关键字修饰时,只有一个线程能够进入该方法或代码块,其他线程则会被阻塞,直到当前线程执行完毕。
synchronized的实现原理:被synchronized修饰的代码块称为同步块,当线程进入同步块时,会尝试获取对象的锁,如果对象没有被加锁或者已经获取了该对象的锁,则锁计数器+1;如果该对象已经被其他线程加锁,则该线程会进入阻塞状态,等待其他线程释放锁。当其他线程释放锁后,等待的线程会被唤醒,并重新尝试获取锁并执行同步块中的代码。同一时刻,只有一个线程可以获取对象的锁并执行同步块中的代码。
对象头:在Java中,每个对象都有一个对象头(Object Header),它用于存储对象的元数据,包括对象的哈希码(hashCode)、锁状态、GC标记状态等信息。对象头的大小是固定的,通常占用8个字节(64位系统)或4个字节(32位系统)。
对象头中最重要的信息是锁状态,用于实现Java中的synchronized关键字的同步机制。锁状态的值可以是无锁状态、偏向锁状态、轻量级锁状态或重量级锁状态,锁状态取决于线程之间的竞争情况和锁的使用方式。
以下方法为synchronized关键字的使用:
java
public class Counter {
private int count;
// synchronized 修饰方法
public synchronized void increment() {
count++;
}
// synchronized 修饰代码块
public void add(int n) {
synchronized (this) {
count += n;
}
}
}
使用synchronized关键字的注意点:
- 方法是实例方法(非静态方法)
优点:简单易用、支持可重用锁。
缺点:性能问题、只能保护代码块或方法。
- volatile关键字:读作"我你太欧"。只能用于修饰变量。使用volatile关键字可以保证变量的可见性,即使多个线程同时访问同一个变量时,保证变量的值是一致的。此外,volatile还具有禁止指令重排的作用。当一个变量被volatile修饰时,所有线程访问该变量都是从主内存中读取最新的值。
可见性:如果两个线程同时对一个volatile变量进行修改,由于volatile变量能够保证可见性,那么它们的修改结果都会被立即刷新到主内存中,从而使得另外一个线程可以读取到最新的值。
读取操作顺序与写入操作顺序一致:如果一个线程读取了volatile变量,而在它进行写入之前,另一个线程也读取了同一个volatile变量,那么在第一个线程写入变量之后,另一个线程读取到的变量值是第一个线程写入的最新值,而不是读取时的值。
指令重排:是指处理器或编译器为了优化程序执行效率,在不改变原有程序执行结果的前提下,改变指令的执行顺序,以达到减少指令执行的等待时间、利用处理器的多级流水线、减少分支预测错误等目的。在单线程环境下,指令重排不会带来任何问题,因为最终执行结果不会发生变化。但在多线程环境下,指令重排可能会导致一些意料之外的结果,例如数据不一致、死锁、无限循环等问题。
以下方法为volatile关键字的使用:
java
public class VolatileExample {
private volatile int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
优点:变量对于所有线程的可见性、顺序读写
缺点:不能保证原子性、频繁读写volatile变量的开销大
- Lock锁:使用Lock锁机制可以实现更加灵活的锁操作,比synchronized关键字更加高效。Lock锁最常用的是可重入锁ReentrantLock,Reentrant读作"瑞恩穿特"。
可重入锁是指可以对同一个锁进行重复的加锁和解锁操作,每次加锁操作都必须对应一个解锁操作,否则锁将一直被占用。synchronized关键字也是可重入锁。
以下方法为ReentrantLock可重入锁的使用:
java
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时释放锁。
java
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.
- 支持公平锁和非公平锁。可重入锁可以指定是公平锁还是非公平锁,默认是非公平锁。
公平锁和非公平锁:公平锁是指多个线程在等待锁时,按照等待的时间先后依次获得锁,即先到先得的策略。非公平锁则是不考虑等待的时间先后,直接去抢占锁,这可能会导致某些线程一直无法获得锁。
- 支持中断响应。可重入锁允许在等待锁的过程中响应中断。
- 支持多条件变量。可重入锁可以为每个条件变量创建一个等待队列,并且可以在条件变量上等待或者唤醒指定数量的线程。
优点:可以重复获取锁避免死锁,性能好,可扩展(公平锁非公平锁、可重入读写锁等)
缺点:代码复杂
- 原子操作:原子操作是一种无需加锁的线程安全操作方式,可以保证多个线程同时访问同一个变量时,仍能保证数据的一致性。Java中通过使用原子操作类中的原子操作方法,实现线程安全。
优点:保证操作的完整性,不需要加锁,保证数据的一致性和可见性
缺点:不能保证并发访问的顺序,不能保证数据的原子性(如果操作的数据比较大,仍然需要加锁来保证原子性),实现比较复杂
- 线程安全的集合类: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线程安全?
- HashMap和Hashtable
区别 | HashMap | Hashtable |
---|---|---|
线程安全性 | 不安全,需要进行额外的同步操作 | 安全 |
null值 | 允许key和value都为null | 不允许 |
初始容量和扩容机制 | 默认初始容量为16,扩容机制是元素数量大于负载因子和数组长度的乘积时,将数组长度翻倍 | 默认初始容量为11,扩容机制是元素数量大于数组长度时,将数组长度翻倍再加1 |
遍历方式 | 通过Iterator实现 | 通过Enumeration实现 |
- HashTable的常见问题:
HashTable线程安全而HashMap线程不安全:Hashtable采用了同步机制来保证线程安全,即在每个公共方法上使用了synchronized关键字来确保同一时间只能有一个线程操作Hashtable。而HashMap则没有采用这种同步机制。
HashTable公共方法源码:
java
// 判断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个位置。
- 实现HashMap的线程安全
- 使用 ConcurrentHashMap
这是Java提供的线程安全的HashMap实现。它通过分段锁(Segment)的方式来实现线程安全,多个线程可以同时访问不同的Segment,从而提高并发度。
ConcurrentHashMap与HashTable的区别:Hashtable 是基于 synchronized 实现线程安全, ConcurrentHashMap 使用了分段锁的方式来实现线程安全。如果需要在多线程环境下使用哈希表,推荐使用 ConcurrentHashMap。
- 使用 Collections.synchronizedMap
这是Java提供的一个工具类,用于将一个非线程安全的Map包装成一个线程安全的Map。它通过对Map的操作加上同步锁(使用synchronized关键字)的方式来实现线程安全。
使用Collections.synchronizedMap实现线程安全代码示例:
java
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。