前言
在java中有很多已经为我们提供好的线程安全的组件,在并发中使用这些组件并不用担心线程安全的问题,使用起来很方便,在本篇本章中我主要总结了八种线程的组件,分别是StringBuffer,HashTable,ConcurrentHashMap ,Vector ,CopyOnWriteArrayList ,BlockingQueue,Collection.synchronList 和所有的原子类。
下面本将介绍其的使用方法以及其为什么是安全的。
StringBuffer
StringBuffer是java中一个线程安全的可变字符序列,可以用来进行字符串的拼接操作。提到StringBuffer就不得不提StringBuilder,二者功能类似,不过StringBuilder速度会更快,StringBuffer速度会慢一些因为里面加了synchronized锁来保证安全。但要注意这二者都比直接使用String进行字符串拼接要快得多。
一些基本的使用:
public static void main(String[] args) {
StringBuffer stringBuffer = new StringBuffer();
//拼接字符串"abc"
stringBuffer.append("abc");
//将前两个个字符换为"asd"
stringBuffer.replace(0,2,"asd");
//删除第一个字符
stringBuffer.deleteCharAt(0);
//获取当前字符串的长度
System.out.println(stringBuffer.length()); ;
}
为什么是安全的,StringBuffer之所以是安全的就是因为其中加入了锁,从源码中我们可以看到
@Override
public synchronized int length() {
return count;
}
@Override
public synchronized int capacity() {
return value.length;
}
@Override
public synchronized void ensureCapacity(int minimumCapacity) {
super.ensureCapacity(minimumCapacity);
}
@Override
public synchronized void trimToSize() {
super.trimToSize();
}
......
StringBuffer中各种方法都加了synchronized锁。
HashTable
Hashtable 和HashMap一样,也是一个散列表,它存储的内容是键值对(key-value)映射。Hashtable 继承于Dictionary,实现了Map、Cloneable、java.io.Serializable接口。Hashtable 的函数都是同步的,同样是加了synchronized锁,因此它是线程安全的。
它的key、value都不可以为null。并且Hashtable中的映射不是有序的。
Hashtable的使用方法和HsahMap类似,都是对散列表的一些操作
public static void main(String[] args) {
Hashtable<Integer,String> hashtable = new Hashtable<>();
hashtable.put(1,"aaa");
hashtable.put(2,"bbb");
hashtable.get(1);
hashtable.clear();
}
从源码中我们同样可以看到加了锁,变为了同步的。
public synchronized V get(Object key) {
……
}
public synchronized V put(K key, V value) {
……
}
public synchronized V remove(Object key) {
……
}
但是这种方案虽然实现了线程安全,但相当于给整个哈希表都加了一把锁,在竞争激烈的多线程场景中性能就会非常差,所以 Hashtable 不推荐使用,推荐使用ConcurrentHashMap
ConcurrentHashMap
上面提到了Hashtable和hashmap类似也是散列表,其实想要使用线程安全的散列表还可以用ConcurrentHashMap ,它是HashMap的线程安全版本,内部也是使用了数组+链表+红黑树。相比于同样线程安全的HashTable来说,效率等各方面都有极大地提高
其使用就不再多说,下面来看其为什么是线程安全的。
在jdk1.7中,ConcurrentHashMap的底层实现是用的数组加链表的方式,并且是采用分段锁的方式来保证线程安全。不过在jdk1.8改变了数据结构中是用数组+链表+红黑树的方式来实现,并且也放弃了原有分段锁的这种方法,采用了cas+synchronized的方式来实现线程安全。
在1.8中一旦因为哈希冲突产生的链表长度超过8就会将节点自动转化为红黑树,
- 首先ConcurrentHashMap 中 synchronized 只锁定当前链表或红黑二叉树的首节点,只要节点 hash 不冲突,就不会产生并发,并且这样锁得范围小,极大提高了性能。
- 其次使用无锁的 CAS 操作来管理其内部结构的一部分,特别是在进行某些关键的更新操作时。CAS 操作允许一个线程在修改变量之前检查变量没有被其他线程改变。这种技术有助于在没有全面加锁的情况下保证数据的一致性。
- 最后,为了保证多线程下得可见性,一些关键字段还加了volatile修饰。使用 volatile变量意味着写入这些变量的值会立即被更新到主内存中,而读取操作会从主内存中进行,这保证了内存的可见性。
下面来看一个例子,例如ConcurrentHashMap中put方法。其大致分为以下几步。
- 首先会根据key算出其哈希值,随后判断是否需要初始化
- 进一步判断,要插入的元素f,在当前数组下标是否第一次插入,如果是就通过 CAS 方式插入
- 判断是否需要扩容
- 如果以上的情况都不满足则利用synchronized锁进行写入数据
- 最后判断是否需要转化为红黑树
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
//根据key算出哈希值
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
//判断是否需要初始化
if (tab == null || (n = tab.length) == 0)
tab = initTable();
//进一步判断,要插入的元素f,在当前数组下标是否第一次插入,如果是就通过 CAS 方式插入
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break;
}
//如果当前位置的 hashcode == MOVED == -1,则需要进行扩容
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
//如果都不满足,则利用 synchronized 锁写入数据
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
//如果数量大于 TREEIFY_THRESHOLD 则要转换为红黑树
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
其他方法中也是进行了一些类似的操作,利用cas和synchronized锁来保证安全。
Vector
Vector
是 Java 中的一个线程安全的动态数组(动态数组也称为可变数组),它实现了 List
接口,可以动态地增加或减少元素的大小。与 ArrayList
类似,但 Vector
是同步的,即它的所有操作都是线程安全的。
基本使用:
public static void main(String[] args) {
Vector<Integer> vector = new Vector<>();
// 添加元素
vector.add(1);
vector.add(2);
vector.add(3);
// 获取元素
System.out.println("Element at index 1: " + vector.get(1));
// 删除元素
vector.remove(0);
// 遍历元素
for (Integer element : vector) {
System.out.println("Element: " + element);
}
}
与上面几个很像,Vector
的方法也都使用了 synchronized
关键字进行同步化,例如 add()
、get()
、remove()
等方法,这意味着每次只允许一个线程访问该方法,并且会对整个方法体进行加锁,防止其他线程同时访问。
例如:
public synchronized void setSize(int newSize) {
....
}
public synchronized int capacity() {
....
}
public synchronized int size() {
....
}
public synchronized boolean isEmpty() {
......
}
CopyOnWriteArrayList
CopyOnWriteArrayList
是 Java 集合框架中的一个并发集合类,它实现了 List
接口,并提供了一种线程安全的并发访问机制。与 Vector
和 ArrayList
不同的是,CopyOnWriteArrayList
的线程安全机制不是通过同步化来实现的,而是通过复制(copy-on-write)机制来保证线程安全性。
当需要进行写操作时(添加、修改、删除元素),CopyOnWriteArrayList
会先复制当前的数组,然后在副本上进行操作,最后将修改后的副本替换原来的数组。这样做的好处是,读操作可以在不受影响的情况下继续进行,不需要加锁或同步。
CopyOnWriteArrayList
适用于读操作频繁、写操作相对较少的场景。由于写操作会触发数组的复制,因此写操作的开销比较大,不适合在写操作频繁的情况下使用。
但是由于写操作会触发数组的复制,因此 CopyOnWriteArrayList
不支持修改操作,如 set()
方法。如果尝试修改集合中的元素,会抛出 UnsupportedOperationException
异常。
基本使用:
public static void main(String[] args) {
CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
// 添加元素
list.add(1);
list.add(2);
list.add(3);
// 遍历元素
for (Integer element : list) {
System.out.println("Element: " + element);
}
}
BlockingQueue
BlockingQueue(阻塞队列)
它表示一种线程安全的队列,支持在队列为空或队列已满时阻塞线程,直到条件满足为止。BlockingQueue
接口提供了一系列的方法,用于向队列中添加元素、从队列中获取元素以及查询队列的状态信息。
基本使用:利用BlockingQueue 实现生产者消费者模式
public class ProducerConsumerExample {
private static final int CAPACITY = 5;
private static final BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(CAPACITY);
static class Producer implements Runnable {
@Override
public void run() {
try {
for (int i = 0; i < 10; i++) {
queue.put(i);
System.out.println("Produced: " + i);
Thread.sleep(1000);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
static class Consumer implements Runnable {
@Override
public void run() {
try {
while (true) {
Integer value = queue.take();
System.out.println("Consumed: " + value);
Thread.sleep(2000);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
public static void main(String[] args) {
Thread producer = new Thread(new Producer());
Thread consumer = new Thread(new Consumer());
producer.start();
consumer.start();
}
}
为什么它是安全的呢?
首先,BlockingQueue
的实现类在队列操作中通常使用了原子性的操作,如 CAS(Compare and Swap)等,确保了多线程环境下的线程安全性。这意味着队列的添加(入队)和获取(出队)操作都是原子性的,不会发生竞态条件。
并且应用了内部锁ReentrantLock来保证线程安全。例如源码中offferFirst方法:
public boolean offerFirst(E e) {
if (e == null) throw new NullPointerException();
Node<E> node = new Node<E>(e);
final ReentrantLock lock = this.lock;
lock.lock();
try {
return linkFirst(node);
} finally {
lock.unlock();
}
}
原子类
Java中提供了一些原子类,原子类包装了一个变量,并且提供了一系列对变量进行原子性操作的方法。我们在多线程环境下对这些原子类进行操作时,不需要加锁,大大简化了并发编程的开发。
目前Java中提供的原子类大部分底层使用了CAS锁(CompareAndSet自旋锁),如AtomicInteger、AtomicLong等;也有使用了分段锁+CAS锁的原子类,如LongAdder等。
Collections工具类
Java提供一组包装方法,将一个普通的基础容器包装成一个线程安全的同步容器。
例如通过Collections.synchronizedSortedSet 包装方法能将一个普通的SortedSet容器包装成一个线程安全的SortedSet同步容器。
synchronizedList()方法将基础List包装成线程安全的列表容器
synchronizedMap()方法将基础Map容器包装成线程安全的容器
synchronizedCollection()方法将基础Collection容器包装成线程安全的Collection容器
其实现方式都是通过加上synchronized锁来实现,因此也具有性能不好的缺点,因此也不推荐使用。