Java 基础之线程_禽兽先生不禽兽的博客-CSDN博客
上一篇博客中记录了线程的一些概念,那多线程既然能与人方便必然也会带来一些问题,这些问题主要与线程的三大特性有关,Java 的一些关键字和锁机制,可以帮助我们解决这些问题。
一、volatile
volatile 是 Java 的一个关键字,它具有以下特点:
- 只能修饰变量,不能修饰方法和代码块;
- 保证可见性、有序性,但不能保证原子性;
- 相对轻量。
根据 volatile 这些特性,我们就可以知道它的适用场景了,它主要应用于一些只对变量有读同步的并发场景中。在上一篇博客文末的两段代码中,我们只需要给变量加上 volatile 关键字,就可以避免可见性和有序性的问题了。
二、synchronized
volatile 只能保证可见性、有序性,那想要保证原子性应该怎么做呢?先看个例子:
package com.qinshou.resume.thread;
import java.util.concurrent.CountDownLatch;
public class SynchronizedDemo {
private static int a = 0;
private static void increaseA() {
a++;
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(2);
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increaseA();
}
countDownLatch.countDown();
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increaseA();
}
countDownLatch.countDown();
}
});
thread1.start();
thread2.start();
countDownLatch.await();
System.out.println("a--->" + a);
}
}
上述代码启动了两个线程做了同样的操作,理想的结果是 a 的值最终为 20000,但实际情况却差强人意,a 的值大概率不到 20000,这还是因为多个线程同时修改一个变量导致的,这个是写操作,所以用 volatile 关键字并没有用,只能在写入时加锁,保证在一个时间内只有一个线程修改该变量,而加锁的方式有两种,使用 synchronized 关键字是最简单的一种,为 increaseA() 方法增加 synchronized 关键字修饰,则可使上面的程序结果为 20000。
private synchronized static void increaseA() {
a++;
}
JVM 会在调用被 synchronized 修饰的方法时会先尝试获取对象锁(每个对象都有一把锁),没获取到就阻塞等待,获取到之后就加锁,于是只能你访问该代码块,该代码块执行完成后再释放锁。
因为这个上锁的操作,所以三大特性在多线程下可能发生的问题就得以解决,每次只有一个线程访问代码块,在执行完后操作的变量会写入主内存,那其他线程再访问,自然是最新的值,可见性得以解决。也因为每次只有一个线程访问代码块,所以即使编译器有优化,指令重排了也无所谓,有序性就可以忽略了。加锁操作保证一个线程中该代码块一定执行完了才会轮到别的线程,正是解决原子性的问题。
synchronized 可以修饰代码块和方法(包括实例方法和静态方法)。
package com.qinshou.resume.thread;
public class SynchronizedDemo {
/**
* synchronized 修饰代码块
*/
public void method1() {
synchronized (this) {
// do sth.
}
}
/**
* synchronized 修饰实例方法
*/
public synchronized void method2() {
// do sth.
}
/**
* synchronized 修饰静态方法
*/
public synchronized static void method3() {
// do sth.
}
}
其实 synchronized 修饰实例方法时,获取的是当前对象的锁,所以 method2 与 method1 中获取的是同一个锁对象,而修饰静态方法时,获取的是类对象的锁。
虽然 method2 与 method1 获取的都是同一个锁对象,但是反编译后的代码是不一样的,利用 javap -c -v 获取反编译后的代码,可以看到有这两部分字节码:
public void method1();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_1
5: monitorexit
6: goto 14
9: astore_2
10: aload_1
11: monitorexit
12: aload_2
13: athrow
14: return
Exception table:
from to target type
4 6 9 any
9 12 9 any
LineNumberTable:
line 5: 0
line 7: 4
line 8: 14
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 9
locals = [ class com/qinshou/resume/thread/SynchronizedDemo, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
public synchronized void method2();
descriptor: ()V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 12: 0
在 method1 中有 monitorenter 和 monitorexit 指令,这其实就是通过对象监视器来进行加锁和释放锁的操作,而 method2 中并没有这一对指令,取而代之的是 flags 中增加了一个 ACC_SYNCHRONIZED 标识,该标识向 JVM 标识该方法是一个同步方法。
三、Lock
除了使用 synchronized 来进行同步外,还可以使用 Lock 对象来进行同步。Lock 是一个接口,它的实现类主要是 ReentrantLock,字面意思是可重入锁(可重入锁和不可重入锁稍后记录),先来看看它怎么用:
package com.qinshou.resume.thread;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockDemo {
private Lock mLock = new ReentrantLock();
private int a = 0;
private void increaseA() {
try {
mLock.lock();
a++;
} finally {
mLock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
CountDownLatch countDownLatch = new CountDownLatch(2);
LockDemo lockDemo = new LockDemo();
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
lockDemo.increaseA();
}
countDownLatch.countDown();
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
lockDemo.increaseA();
}
countDownLatch.countDown();
}
});
thread1.start();
thread2.start();
countDownLatch.await();
System.out.println("a--->" + lockDemo.a);
}
}
上述代码同使用 synchronized 关键字结果相同,同时可以看到使用 ReentrantLock 对象我们需要自己手动释放锁,使用最好将释放锁的操作放到 finally 代码块中,否则一个不小心就有可能造成死锁(死锁即一个线程获取锁之后无法释放,导致后续线程无法再获得锁,从而正常工作)。
那么为什么我们还要用 ReentrantLock 呢?换句话说,它比 synchronized 好在哪儿?总结一下它们两个的异同:
相同点
- 都能保证线程的三大特性。
- 都是可重入锁。
不同点
- ReentrantLock 性能较之于 synchronized 更好(JDK 1.6 之后 synchronized 有过升级,升级后性能其实也不差)。
- synchronized 加锁释放锁操作由 JVM 控制,程序异常也会释放锁,这一点可以看看上面反编译后的代码,反编译后会有两个 monitorexit 指令,第一个指令是方法正常退出时释放锁,而第二个就是方法异常时释放锁。而 ReentrantLock 需要自己手动加锁和释放锁。
- 同样,因为 synchronized 加锁释放锁操作由 JVM 控制,所以一个线程如果没有获取到锁就只能一直等待,而 ReentrantLock 可以尝试在超时时间内获取锁,超时时间内没有获取到锁则可以执行其他逻辑。
- ReentrantLock 可以配合 Condition 对象,更灵活。
- ReentrantLock 可以实现公平锁(公平锁和非公平锁稍后记录)。
所以我们大致上知道,synchronized 简单易用,而 ReentrantLock 功能更强。
四、Condition
有时候我们的线程获取到了锁对象,进入了临界区,但可能之后后续代码的时候,需要满足某些条件,于是 ReentrantLock 有了 Condition 对象,它可以帮助我们管理那些获得了锁但因为条件不满足而不能正常工作的线程。
Condition 对象主要会用到如下方法:
/**
* 当前线程进入阻塞状态直到被 signal 或中断。
*/
void await() throws InterruptedException;
/**
* 唤醒一个阻塞在 Condition 上的线程。
*/
void signal();
/**
* 唤醒所有阻塞在 Condition 上的线程。
*/
void signalAll();
在每个 Condition 中,都维护着一个队列,每当执行 await() 方法,都会将当前线程封装为一个节点,并添加到条件等待队列尾部。然后释放与 Condition 对象绑定的锁,在释放锁的同时还会并唤醒阻塞在锁的入口等待队列中的一个线程,完成以上操作后再将自己阻塞。
举个栗子就能大概理解 Condition 对象能做什么了,我们用 ReentrantLock + Condition 来实现一个阻塞队列:
package com.qinshou.resume.thread;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class ConditionDemo {
private List<String> mList = new LinkedList<>();
private ReentrantLock mLock = new ReentrantLock();
private Condition mFull = mLock.newCondition();
private Condition mEmpty = mLock.newCondition();
public void push(String string) throws InterruptedException {
try {
mLock.lock();
if (mList.size() == 5) {
System.out.println("队列满了");
// 类似 wait()
mFull.await();
}
// 类似 notify()
mEmpty.signal();
mList.add(string);
} finally {
mLock.unlock();
}
}
public String pop() throws InterruptedException {
try {
mLock.lock();
if (mList.size() == 0) {
System.out.println("队列为空");
mEmpty.await();
}
mFull.signal();
// FIFO
return mList.remove(0);
} finally {
mLock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
ConditionDemo conditionDemo = new ConditionDemo();
Thread writeThread = new Thread(new Runnable() {
@Override
public void run() {
// 先 sleep 一下再插入消息,让读取消息的线程先运行,模拟队列为空的场景
try {
Thread.sleep(1000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
for (int i = 0; i < 10; i++) {
try {
conditionDemo.push("Hello World" + i);
System.out.println("插入消息--->" + ("Hello World" + i));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
Thread readThread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10; i++) {
// 取出两条消息后 sleep 一下,模拟队列满了的场景
if (i == 2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
}
try {
String string = conditionDemo.pop();
System.out.println("读取消息--->" + string);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
writeThread.start();
readThread.start();
}
}
程序运行结果如下:
我们成功模拟出了阻塞队列的效果,从上述代码和运行结果中我们可以明白当某个条件不满足的时候,当前线程是阻塞的,比如我们刚开始读线程在队列为空时,并没有继续读取消息。同时这个锁对象也是被释放掉了,否则写线程是获取不到锁对象的。
用 synchronized 当然也能实现阻塞队列,但写法不会这么优雅。
五、可重入锁和不可重入锁
可重入锁即获取锁对象后,获取到该锁对象的线程可以继续获取该锁对象。
不可重入锁就为之相反,获取锁对象后,获取到该锁对象的线程不可以继续获取该锁对象。
synchronized 和 ReentrantLock 都是可重入锁,可重入锁主要解决的就是死锁问题。先看一段代码:
package com.qinshou.resume.thread;
public class ReentrantLockDemo {
public synchronized void method1() {
method2();
}
public synchronized void method2() {
}
}
我们知道 synchronized 修饰实例方法时,获取的是当前对象的锁,那 method1 和 method2 都是用的同一把锁,试想一下,如果这个锁是不可重入锁,那 method1 调用 method2 时,因为 method1 还没有执行完成,所以还没有释放锁,导致 method2 获取不到锁,一直阻塞,于是 method1 也用于无法结束,导致死锁。
ReentrantLock 解决的也是同一问题,但是需要注意的是由于 ReentrantLock 是手动加锁和释放锁,所以加锁多少次,就一定要释放多少次锁。ReentrantLock 实现可重入锁的关键代码在于 Sync 的 tryLock() 方法:
abstract static class Sync extends AbstractQueuedSynchronizer {
...
final boolean tryLock() {
Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (getExclusiveOwnerThread() == current) {
// 已经加过锁,但之前获取到锁的线程就是当前线程,可以再次获取锁,锁数量 +1
if (++c < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(c);
return true;
}
return false;
}
...
}
六、公平锁与非公平锁
synchronized 是非公平锁。ReentrantLock 默认也是非公平锁,但是它在创建时指定入参为 true 来构建公平锁:
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
非公平锁是所有抢占锁资源的线程不分先来后到地竞争锁,公平锁则是每次等待最长的线程获取锁,即先来先得。
上面看到 ReentrantLock 会根据构造方法参数不同而创建不同的 Sync 对象,那我们就简单看看这两个对象获取锁的地方:
static final class NonfairSync extends Sync {
...
protected final boolean tryAcquire(int acquires) {
if (getState() == 0 && compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
}
static final class FairSync extends Sync {
...
protected final boolean tryAcquire(int acquires) {
if (getState() == 0 && !hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
}
其中 getState() 方法是用来判断当前是否有线程占有锁的,如果没有,则返回 0,否则大于 0。compareAndSetState() 方法主要是用于抢占锁。NonfairSync 的 tryAcquire() 方法只是调用了 getState() 方法后就直接开始尝试抢占锁。而 FairSync 的 tryAcquire() 方法在调用了 getState() 方法后还调用了 hasQueuedPredecessors() 判断当前队列中是否还有线程在等待获取锁,如果有的话,就不尝试抢占锁,而是乖乖去队尾排队了,以此来实现公平。
eg:
package com.qinshou.resume.thread;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class FairLockDemo {
private static class MyRunnable implements Runnable {
private final Lock mLock;
public MyRunnable(Lock lock) {
super();
mLock = lock;
}
@Override
public void run() {
for (int i = 0; i < 5; i++) {
mLock.lock();
try {
Thread.sleep(100);
System.out.println(Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
mLock.unlock();
}
}
}
}
public static void main(String[] args) throws InterruptedException {
// Unfair
System.out.println("Unfair lock");
Lock lock = new ReentrantLock();
Runnable runnable = new MyRunnable(lock);
Thread thread0 = new Thread(runnable);
Thread thread1 = new Thread(runnable);
thread0.start();
thread1.start();
thread0.join();
thread1.join();
// Fair
System.out.println("Fair lock");
lock = new ReentrantLock(true);
runnable = new MyRunnable(lock);
Thread thread2 = new Thread(runnable);
Thread thread3 = new Thread(runnable);
thread2.start();
thread3.start();
thread2.join();
thread3.join();
}
}
用非公平锁时,两个线程获取到锁的几率毫无规律,使用公平锁后,两个线程交替获取到锁:
相应的,由于公平锁会增加额外判断,因此性能会有所降低,所以在没有特别需求的情况下尽量使用非公平锁。
七、锁的其他概念
上文有提到 synchronized 在 JDK1.6 之后有过升级,优化的手段包括自适应自旋锁、轻量级锁、偏向锁、锁粗化、锁消除等,这些手段比较偏向于概念,不好模拟,所以简单记录一下这些概念。
1.偏向锁
偏向锁是指这个锁会偏向于第一个获得它的线程。如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将不需要再进行同步。
2.轻量级锁
当锁是偏向锁时,如果有其他线程访问锁,那偏向锁就会升级为轻量级锁,轻量级锁会用自旋的形式尝试获取锁。
3.重量级锁
自旋达到一次次数后还不能获取到锁,轻量级锁就会升级成重量级锁,也就是尝试获取锁的线程会以在没有获取到锁的时候就会进入阻塞状态。
4.自适应自旋锁
刚才说到轻量级锁提到了自旋,对于线程同步来说,线程状态之间的切换是比较重量级的,如果一个同步操作中逻辑代码执行的时间很短的话,那显然同步操作就很重了。如果多个线程并发时,后续获取锁的线程能够先在用户态下等待一下,如果持有锁的线程很快释放锁了,就不用切换到内核态了,那岂不是很棒?
自旋锁就是这样一个概念,在遇到同步操作时,先执行一段空循环,循环体内啥也不做,只是为了避免线程状态的切换。但是自旋操作也是会耗费 cpu 资源的,如果此时锁被占用,那影响倒还不大,但如果自旋时锁已释放,那 cpu 就是白白空转了。
在 JDK 1.4 中已有自旋锁,但默认是关闭的,JDK 1.6 才默认开启,因为 JDK 1.6 时引入了自适应自旋锁的概念,让自旋的时间不再固定,而是由上一次在该锁上自旋的时间决定,如果自旋成功,则下次自旋成功了,则下次自旋次数会增多,如果失败,则下次自旋的次数会减少甚至取消自旋。
5.锁粗化
锁粗化就是编译器在发现一段代码中频繁使用同一个锁对象时,会将所有的锁合并为一个。
举个栗子:
package com.qinshou.resume.thread;
import java.util.Hashtable;
import java.util.Map;
public class LockOptimizeDemo {
Map<Integer, String> mHashtable = new Hashtable<>();
public void lockCoarsening() {
mHashtable.put(1, "A");
mHashtable.put(2, "B");
mHashtable.put(3, "C");
}
}
上述代码中由于 Hashtable 是一个线程安全的集合,所以每次调用 put() 方法都是会加锁的,如果进行锁粗化的话就在第一个 put 前加锁,最后一次 put 后释放锁即可。
6.锁消除
锁消除的意思是如果编译器检测到锁对象只会被一个线程使用,不存在其他线程竞争的情况,那这个锁就是没有必要的,可以去掉。
举个栗子:
package com.qinshou.resume.thread;
import java.util.Hashtable;
import java.util.Map;
public class LockOptimizeDemo {
public void lockRemove() {
Map<Integer, String> hashtable = new Hashtable<>();
hashtable.put(1, "A");
hashtable.put(2, "B");
hashtable.put(3, "C");
}
}
上述代码中调用 put() 方法虽然会加锁,但是 hashtable 只是一个局部变量,并不会其他线程访问,所有同步是没有必要的,这个锁可以去掉。
八、总结
并发编程,博大精深,如果我们能很好的控制线程后,再对锁进制深入理解,相信对多线程编程得心应手不是问题。