Java高效并发

线程安全

记得有一次电话面试,被问到什么是线程安全?这个问题看似觉得一定能够答上来,但是我却没有答好。有一本书的作者将线程安全定义如下:

当多个线程访问一个对象时,如果不考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的

再看看维基百科的定义

线程安全是编程中的术语,指某个函数、函数库在多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成

总结一下:在多线程下,对于共享变量,不需要进行额外的同步,能够保证程序执行获得正确的结果

Java中的线程安全

一提到保障线程安全,可能就马上想到sychronized关键字进行同步,加上互斥锁,使用可重入锁等,这些都是显式加锁机制。其实,将变量定义为不可变也能够达到线程安全的目的。

《深入理解Java虚拟机》一书中,可将各种操作共享数据分为五类:不可变,绝对线程安全,相对线程安全,线程兼容和线程独立。

不可变

不可变的对象一定是线程安全的,典型的String对象,对这些对象的操作不用加锁,访问速度也很快。此外,如果定义一个final关键字修饰的基本变量,也是不可变的。如果要使得对象不可变,那么要保证对象的行为不会对状态产生影响,可将状态变量设置为final。基本变量的包装类也是不可变的。

绝对线程安全

如定义所描述。注意,Vector是一个线程安全的容器,它的方法都被加上sychronized关键字,但是,并不能表示它是完全线程的,它也需要额外的同步手段才能运行正确。

相对线程安全

包装对象的单独操作是线程安全的,在调用时不需要额外的同步保护,但是对于特性的连续调用,需要使用同步手段。例如:Vector,HashTable

线程兼容

对象本身不是线程安全的,需要调用端使用同步保证线程安全,Java绝大多数类是这种情况。如ArrayList, HashMap等

线程对立

无论是否采用同步措施,都无法在多线程环境下并发使用的代码,这种排斥多线程的行为很少出现,应尽量避免。

线程安全的实现

虚拟机提供了同步与锁机制来实现线程安全

互斥同步

最常见的线程安全手段,保证在多线程情况下,共享数据在同一时刻纸杯一条线程使用。互斥是实现同步的一种手段临界区,互斥量和信号量是实现互斥的方式。最基本的互斥同步的手段是synchronized关键字,在编译时,它会在同步块前后形成monitorenter和monitorexit两个字节码指令,这两个字节码需要一个参数,即需要锁住和解锁的对象。如果synchronized显示指定了,那么给该对象的锁计数器加1或减1;如果没有指定,则要判断synchronized修饰的是实例方法还是类方法(static),前者锁定实例对象,后者锁定类对象。当执行monitorenter字节指令时,会尝试获取锁,如果获取失败,则会阻塞。

注意,synchronized关键字会阻塞其他线程,而阻塞这个操作是有操作系统帮助完成,因此,需要切换到内核态,比较耗时,因此,synchronized是一个重量级的操作

java.util.concurrent包的ReettranLock来实现同步,功能上与synchronized一致,区别在于表现为API级别的。此外,他还具有一些高级功能:

  1. 等待可中断,表示线程等待一段时候后还没有得到锁,可放弃等待去处理其他事

  2. 可实现公平锁,多个线程等待同一个锁,先等先得

  3. 锁绑定多个条件(Condition)

下面针对锁,举一个经典的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class KunBuffer{
final Lock lock = new ReentrantLock();
final Condition notFull = lock.newCondition(); //当需要写时,必须拥有该条件,否则await
final Condition notEmpty = lock.newCondition(); //当需要读时,必须要拥有该条件,否则await
final Object [] items = new Ojbect[100]; //缓存区
int i = 0,j = 0,count = 0;
public void write(Object o) throws InterruptedException {
 lock.lock(); //尝试获取锁,失败则阻塞
try{
  while(count == items.length) //Full! 无法写,释放锁,进入等待状态
   notFull.await();  
 //拥有锁后,写
items[++writePtr] = o;
if(writePtr == 100) writePtr = 0;
++count;
 //队列非空,可以让读线程读
notEmpty.signal();
} finally{
lock.unlock();
}
}
public void read() throws InterruptedException {
 lock.lock(); //尝试获取锁,失败则阻塞
try{
  while(count == 0) //Empty! 无法读,释放锁,进入等待状态
   notEmpty.await();  
 //拥有锁后,写
 Object o = items[readPtr];
if(readPtr == 100) readPtr = 0;
--count;
 //队列非满,可以让写线程写
 notFull.signal();
} finally{
lock.unlock();
}
}
}

最后,对于synchronizedReettranLock,在jdk1.6后,性能其实差不多的,推荐优先使用原生态的synchronized,如果特殊要求,可用后者。

非阻塞同步

上面互斥同步属于阻塞同步,线程要独占资源,其他线程要被挂起,对于性能上回造成很大的消耗(用户态内核态的来回切换,线程状态的保存等),这种属悲观并发。 除了这种方法,还有乐观并发的非阻塞同步。其主要思想是:

每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。

CAS就是一种乐观锁思想的应用. 这是一个CPU的指令,需要三个操作数,V(内存实际值),A(旧的预期值)和B(新的值),当且仅当A==V时,采取更新V为B。以上操作为原子。缺点:1) ABA问题; 2)只能保证一个共享变量 3)长时间自旋对CPU压力太大。

锁优化

由上可知,锁机制虽然保证了同步,但是对性能的消耗很大,因此,为了提高同步时使用锁的性能,jdk1.6对锁进行了很多优化

自旋锁和自适应自旋

自旋锁:当获取不到锁时,不进入阻塞状态,不放弃处理执行时间,进行忙循环(自旋),这样避免了线程挂起和恢复的开销。要求必须是多核处理器,并且适合锁被占用的时间很短,自旋一会就能马上获得锁的场景。默认自旋10次

自适应的自旋锁:自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间和锁的拥有者的状态决定

锁消除

虚拟机根据变量是否逃逸来判断是可不可以去掉不必要的同步机制。

锁粗化

如果一系列的连续操作都是对同一个对象反复加锁和解锁,甚至加锁和解锁出现在循环体中,即使没有竞争,也会带来不必要的性能消耗。

轻量级锁

首先需要看看对象的内存布局,对象头会存储对象运行时的数据,如HashCode,GC年龄和锁标志等,官方称为mark word;还有一部分指向方法区对象类型的指针,数组额外存长度。

当代码进入同步块时:

  1. 如果对象没有被锁定(即标志位时01),虚拟机首先在当前线程栈帧中建立名为锁记录的(Lock Record)的空间,存储对象mark word的拷贝。

  2. 虚拟机使用CAS操作来更新mark word为指向栈帧中Lock Record指针

  3. 如果2成功,表示该线程拥有该对象的锁,将对象的mark word标志设为00(轻量级锁

  4. 如果2失败,检查mark word是否指向当前的栈帧,如果是,表示已经拥有锁,进入同步块,否则被抢占了,膨胀成重量级锁,标志位变成10,之后来的线程都要阻塞

它的优点是基于绝大多数的共享数据都是没有竞争的互斥访问的,所以不需要加重量级锁,使用cas造作避免了使用互斥量的开销

偏向锁

锁会偏向于第一个获取它的线程,如果接下来的执行过程,没有其他线程获取,持有偏向锁的线程无需同步就可直接进入。原理是通过CAS把线程ID写入mark word中,当下次进入发现ID相同时,则无需同步,否则撤销偏向,根据情况进入轻量等