您的位置:首页 > Web前端

Java 线程安全之volatile\StringBuffer\ArrayList\HashMap

2017-08-29 15:19 459 查看

一、volatile

1.1 volatile并非线程安全的

Java语言包含两种内在的同步机制:同步块(synchronize关键字)和volatile 变量。但是其中 Volatile 变量虽然使用简单,有时候开销也比较低,但是同时它的同步性较差,而且其使用也更容易出错。下面我们先使用一个例子来展示下volatile有可能出现线程不安全的情况:

public class ShareDataVolatile {
//同时创建十个线程,每个线程自增100次
//主程序等待3秒让所有线程全部运行完毕后输出最后的count值

//使用volatile修饰计数变量count
public volatile static int count=0;
public static void main(String[] args){
final ShareDataVolatile data  = new ShareDataVolatile();
for(int i=0;i<10;i++){
new Thread(
new Runnable(){
public void run(){
try{
Thread.sleep(1);
}catch(InterruptedException e){
e.printStackTrace();
}
for(int j=0;j<100;j++){
data.addCount();
}
System.out.print(count+" ");
}
}
).start();
}
try{
Thread.sleep(3000);
}catch(InterruptedException e){
e.printStackTrace();
}
System.out.println();
System.out.print("count="+count);
}
public void addCount(){
count++;
}
}
运行结果: 

200 200 416 585 755 742 513 513 501 855 

count=855 

多次运行结果最后的count都不是预计的1000,这说明使用volatile变量并不能保证线程安全。

1.2 原因分析

锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility)。互斥即一次只允许一个线程持有某个特定的锁,因此可使用该特性实现对共享数据的协调访问协议,这样,一次就只有一个线程能够使用该共享数据。可见性要更加复杂一些,它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的 —— 如果没有同步机制提供的这种可见性保证,线程看到的共享变量可能是修改前的值或不一致的值,这将引发许多严重问题。 

Volatile 变量具有 synchronized 的可见性特性,但是不具备原子特性。这就是说线程能够自动发现 volatile 变量的最新值。Volatile 变量可用于提供线程安全,但是只能应用于非常有限的一组用例:多个变量之间或者某个变量的当前值与修改后值之间没有约束。(也可表述为如下两点:1)对变量的写操作不依赖于当前值。2)该变量没有包含在具有其他变量的不变式中 )

因此,单独使用 volatile 还不足以实现计数器、互斥锁或任何具有与多个变量相关的不变式(Invariants)的类(例如 “start <=end”)。 

所以例子中虽然增量操作(count++)看上去类似一个单独操作,实际上它是一个由读取-修改-写入操作序列组成的组合操作,必须以原子方式执行,而 volatile 不能对组合操作提供必须的原子特性。实现正确的操作需要使 count 的值在操作期间保持不变,而 volatile 变量无法实现这点。

二、StringBuilder和StringBuffer

2.1 StringBuilder和StringBuffer的区别

HashTable是线程安全的,很多方法都是synchronized方法,而HashMap不是线程安全的,但其在单线程程序中的性能比HashTable要高。StringBuffer和StringBuilder类的区别也在于此,新引入的StringBuilder类不是线程安全的,但其在单线程中的性能比StringBuffer高。

2.2 String/StringBuilder/StringBuffer

三者在执行速度方面的比较:StringBuilder >  StringBuffer  >  String

a. String 是不可变的对象, 因此在每次对 String 类型进行改变的时候其实都等同于生成了一个新的 String 对象,然后将指针指向新的 String 对象,所以经常改变内容的字符串最好不要用 String ,因为每次生成对象都会对系统性能产生影响,特别当内存中无引用对象多了以后, JVM 的 GC 就会开始工作,那速度是一定会相当慢的。

b. StringBuffer和StringBuilder是可变类,任何对它们所指代的字符串的改变都不会产生新的对象。

c. StringBuffer 中的方法大都采用了 synchronized 关键字进行修饰,因此是线程安全的,而 StringBuilder 没有这个修饰,可以被认为是线程不安全的。 

2.3 StringBuilder的扩容机制

1.StringBuffer()的初始容量可以容纳16个字符,当该对象的实体存放的字符的长度大于16时,实体容量就自动增加。StringBuffer对象可以通过length()方法获取实体中存放的字符序列长度,通过capacity()方法来获取当前实体的实际容量。

2.StringBuffer(int size)可以指定分配给该对象的实体的初始容量参数为参数size指定的字符个数。当该对象的实体存放的字符序列的长度大于size个字符时,实体的容量就自动的增加。以便存放所增加的字符。

3.StringBuffer(String s)可以指定给对象的实体的初始容量为参数字符串s的长度额外再加16个字符。当该对象的实体存放的字符序列长度大于size个字符时,实体的容量自动的增加,以便存放所增加的字符。

三、ArrayList和Vector

3.1 ArrayList和Vector的区别

ArrayList虽然是非线程安全的,但如果你想使用线程安全的ArrayList,可以在ArrayList的基础上,通过同步块来实现,或者使用同步包装器(Collections.synchronizedList),还可以使用J.U.C中的CopyOnWriteArrayList。但对于Vector,在其基础之上没有办法获得非线程安全的Vector(无法解耦)。这说明,在设计Vector时,没有做好分离性(数据结构功能和同步功能的分离)。如果用户知道自己是在单线程情况下运行,那么Vector本身的线程安全就没有必要了,耗费性能。

3.2 ArrayList的实现

ArrayList在读取时添加会抛出异常:

final ArrayList<String> list = new ArrayList<String>(); // 多线程共享的ArrayList
for(int i=0;i<100;i++) // 多个线程同时进行写操作
{
new Thread(new Runnable(){
@Override
public void run() {
for(int j=0;j<1000;j++)
{
list.add("hello"); // 多线程下,此处引发ArrayIndexOutOfBoundsException
}
}}).start();
}


ArrayList的内部实现如下:

public class ArrayList<E>
{
private Object[] elementData;      // 存储元素的数组。其分配的空间长度是capacity。
private int size;                  // elementData存储了多少个元素。

public ArrayList(){this(10);};     // 默认capacity是10

boolean add(E e)
{
ensureCapacityInternal(size + 1);  // capacity至少为 size+1
elementsData[size++]=e;            // size++
return true;
}
void ensureCapacityInternal(int minCapacity){
if(minCapacity > elementData.length)     // 扩容
grow(minCapacity);
}
void grow(int minCapacity){
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1);     // 约是原先的1.5倍。
elementData = Arrays.copyOf(elementData,newCapacity );
}
}
如何实现线程安全的ArrayList?

#1:自己手动同步


public static List<E> list = ... ;

lock.lock();

list.add();

lock.unlock();
#2:使用同步包装器

List<E> syncList = Collections.synchronizedList(new ArrayList<E>());
迭代时,需要包含在同步块当中

synchronized(syncList){

    while(Iterator<E> iter = syncList.iterator();iter.hasNext();){}

}

#3:使用J.U.C中的CopyOnWriteArrayList

3.3 Vector的实现

Vector内部使用sychronized实现同步

public class Vector
{
Object[] elementData;       // 存放元素的数组
int elementCount;           // 存放元素的实际数量,默认的容量(capacity)是10
int capacityIncrement;      // 当容量占满时,扩容量,如果未指定,则原先的2倍(doubled)

// 构造函数
public Vector(int initialCapacity/* 初始容量 */,int capacityIncrement/*扩容量*/){}
}


其 capacity()/size()/isEmpty()/indexOf()/lastIndexOf()/removeElement()/addElement() 等方法均是 sychronized 的,所以,对Vector的操作均是线程安全的。

3.4 安全感只是错觉

Vector的线程安全仅指单个操作不出现两个线程同时进行,但复合操作并不能保证线程安全。

if (!vector.contains(element))
vector.add(element);
...
}


这是经典的 put-if-absent 情况,尽管 contains, add 方法都正确地同步了,但作为 vector 之外的使用环境,仍然存在  race condition: 因为虽然条件判断 if (!vector.contains(element))与方法调用 vector.add(element);  都是原子性的操作 (atomic),但在 if 条件判断为真后,那个用来访问vector.contains 方法的锁已经释放,在即将的 vector.add 方法调用
之间有间隙,在多线程环境中,完全有可能被其他线程获得 vector的 lock 并改变其状态, 此时当前线程的vector.add(element);  正在等待(只不过我们不知道而已)。只有当其他线程释放了 vector 的 lock 后,vector.add(element); 继续,但此时它已经基于一个错误的假设了。

单个的方法 synchronized 了并不代表组合(compound)的方法调用具有原子性,使 compound actions  成为线程安全的可能解决办法之一还是离不开intrinsic lock (这个锁应该是 vector 的,但由 client 维护):

// Vector v = ...
public  boolean putIfAbsent(E x) {
synchronized(v) {
boolean absent = !contains(x);
if (absent) {
add(x);
}
}
return absent;
}


业务逻辑的安全性不应该由Vector保证,把业务还给业务,Vector只解决了其内部的安全性。

四、HashMap和HashTable

HashMap和Hashtable都是Map接口的典型实现类,它们之间的关系完全类似于ArrayList和Vector的关系:Hashtable是一个古老的Map实现类,它从JDK1.0起就已经出现了,当它出现时,Java还没有提供Map接口,所以它包含了两个繁琐的方法,即elements()(类似于Map接口定义的values()方法)和keys()(类似于Map接口定义的keySet()方法),Java8改进了HashMap的实现,使用HashMap存在key冲突时依然具有较好的性能。

除此之外,Hashtable和HashMap存在两点典型区别。

(1)Hashtable是一个线程安全的Map实现,但HashMap是线程不安全的实现,所以HashMap比Hashtable的性能高一点;但如果有多个线程访问同一个Map对象时,使用Hashtable实现类会更好。

多线程环境下,使用Hashmap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。

(2)Hashtable不允许使用null作为key和value,如果试图把null放进Hashtable中,将会引发空指针异常;但HashMap可以使用null作为key或value。

HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下。因为当一个线程访问HashTable的同步方法时,其他线程访问HashTable的同步方法时,可能会进入阻塞或轮询状态。如线程1使用put进行添加元素,线程2不但不能使用put方法添加元素,并且也不能使用get方法来获取元素,所以竞争越激烈效率越低。

HashTable容器在竞争激烈的并发环境下表现出效率低下的原因,是因为所有访问HashTable的线程都必须竞争同一把锁,那假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效的提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术(*JDK7),首先将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。

HashMap在JDK8已经做了数据结构上的优化,ConcurrentHashMap增加了红黑树,底层依然由“数组”+链表+红黑树的方式思想,但是为了做到并发,又增加了很多辅助的类,例如TreeBin,Traverser等对象内部类。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: