这篇文章主要分享一下多线程和锁的基础使用;
1.为什么要使用多线程?
假如你刚刚下班回家,你想自己煮点粥喝,在煮粥的时候,盲猜你也不会待在电饭煲旁边就等着吧?干等的请回……在等待的这段时间,完全可以做一些别的事情,例如:打打游戏?洗个衣服?炒个菜?然后等粥煮好了之后,还可以一边喝粥一边看电影,这在某种程度也可以看做是多线程。
虽然一个CPU同一时刻只能执行一个程序,但是为什么我们电脑上的电影、微信中的聊天,还有播放的音乐等等似乎都是同步进行的?
这是因为CPU的执行速度非常快,它在这几个程序之间来回切换执行,让我们看起来就好像就是在同时进行一样;使用多线程,我们可以“同时”去做不一样的事情,将CPU的高性能充分利用起来;
2.什么是锁?
假如我现在想要上厕所,而当我进去之后,不希望别人再进来(毕竟这种事情是比较私密的嘛),那我拿一把锁将门锁住,后面来的人就无法进入,只能在外面等着,只有等到我出来之后,然后将锁交给下一个人,这个人再上;这就是锁的作用;
3.为什么使用锁?
来看一段代码:
private static int a = 0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[100];
CountDownLatch latch = new CountDownLatch(threads.length);
for (int i = 0;i < threads.length;i++){
threads[i] = new Thread(() -> {
for (int j = 0;j < 10000;j++){
a++;
}
latch.countDown();
});
}
//启动100个线程
Arrays.stream(threads).forEach((t) -> t.start());
latch.await();
System.out.println(a);
}
启动100个线程对变量a进行累加操作,每个线程对a执行10000次累加操作,程序结束之后,正常情况下,我们得到a的值是1000000,但是实际呢?
我运行了五次代码,得到了五次不同的结果:969393、990229、981436、985627、987398
为什么会产生这种结果呢?
变量是如何被修改的:线程1拿到变量a的值,修改完成之后,将新值存入a中。这是一次完整的值的修改过程。
但是呢,我们启动了一百个线程,可能存在这种情况:线程1在修改完成,往a中存过程中,线程2又去拿到了a的值,也就是0,进行累加操作,等到线程2操作完成时,线程1已经将a的值改为了1,那么线程2继续把1存了进去,两次线程对a进行累加,但是值却累加了一次。这就可以解释为什么出现最终结果不足1000000的原因了。
而出现这种问题的本质就是没有使用锁;
如果要保证结果的正确性,那么我们需要在线程对变量a进行修改的过程中将a保护起来,直到该线程对a完成了累加操作,这个时候别线程才可以对a进行操作;
而保护的操作就是加锁;
上面的代码可以修改为:
private static int a = 0;
public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[100];
Object o = new Object();
CountDownLatch latch = new CountDownLatch(threads.length);
for (int i = 0;i < threads.length;i++){
threads[i] = new Thread(() -> {
synchronized (o) {
for (int j = 0; j < 10000; j++) {
a++;
}
latch.countDown();
}
});
}
Arrays.stream(threads).forEach((t) -> t.start());
latch.await();
System.out.println(a);
}
这时程序运行的结果就可以保证为1000000
4.如何使用锁?
先来看一道面试题吧:
使用两个线程交替打印出 1a2b3c4d5e……
下面是多种实现方法:
version 1.0 LockSupport
static Thread t1 = null,t2 = null;
public static void main(String[] args) {
char[] num = "123456789".toCharArray();
char[] word = "abcdefghi".toCharArray();
t1 = new Thread(() -> {
for (char n : num) {
System.out.print(n);
java.util.concurrent.locks.LockSupport.unpark(t2);
java.util.concurrent.locks.LockSupport.park();
}
});
t2 = new Thread(() -> {
for(char w : word){
java.util.concurrent.locks.LockSupport.park();
System.out.print(w);
java.util.concurrent.locks.LockSupport.unpark(t1);
}
});
t1.start();
t2.start();
}
上面的代码中,先是定义两个线程t1和t2,然后给出了两个数组,分别为数字数组和字符数组,通过控制线程对两个数组中的内容交替进行输出;
程序一开始,如果最先调度的是t1,那么它先打印第一个数字,然后t1将“许可证”交给t2,再禁止t1被调度;
如果先调度的是t2,那么t2会被禁止调度,然后cpu就会去调度t1,重复上面的步骤,然后t2打印,然后再将“许可证”交给t1,等到两个线程循环结束之后,我们就可以拿到最终打印的结果了。
version 2.0 自旋
enum ReadyToRun{T1,T2}
public class Thread_CAS {
static volatile ReadyToRun r = ReadyToRun.T1;
public static void main(String[] args) {
char[] num = "123456789".toCharArray();
char[] word = "abcdefghi".toCharArray();
new Thread(() -> {
for (char n : num) {
while (r != ReadyToRun.T1){}
System.out.print(n);
r = ReadyToRun.T2;
}
},"t1").start();
new Thread(() -> {
for (char w : word) {
while (r != ReadyToRun.T2){}
System.out.print(w);
r = ReadyToRun.T1;
}
},"t2").start();
}
}
上面的version2.0用到了自旋锁的概念,什么是自旋锁?简单说,一个等着上厕所的人在厕所门口打转;我们定义了一个枚举类和两个枚举变量T1和T2,我们新建一个枚举类型的变量r取值T1,程序启动之后,如果最先调度的是t2线程,可以看到这里是写了一个while循环的,循环体为空,如果其中的条件符合,那么它会形成死循环,这就是自旋,然后等到条件不满足之后,才会跳出循环执行下面的代码;当t2阻塞时,t1就有机会被调度了,很明显t1的while是不符合条件的,那么它直接往下走,打印出一个数字之后,然后将变量r取值为T2,这个时候,t1线程又回到了自旋的状态,那么调度器又去调度t2线程,这个时候t2中的while循环终于不符合条件了,代码向下走,然后打印出了一个字符,再将r取值为T1,重复上述步骤,最终,我们将得到打印的结果;
version 2.1
static AtomicInteger atomic = new AtomicInteger(1);
public static void atomic(){
char[] num = "123456789".toCharArray();
char[] word = "abcdefghi".toCharArray();
new Thread(() -> {
for (char n : num) {
while (atomic.get() != 1){}
System.out.print(n);
atomic.set(2);
}
},"t1").start();
new Thread(() -> {
for (char w : word) {
while(atomic.get() != 2){}
System.out.print(w);
atomic.set(1);
}
},"t2").start();
}
2.1版本的代码和2.0版本的核心思想是相同的,都是通过变量判断形成自旋来阻塞线程,然后迫使调度器去调度另一个线程,在另一个线程执行完成后再次修改变量值,之后形成自旋,然后调度器再调用另一个线程;
version 3.0 synchronized
public static void main(String[] args) {
final Object o = new Object();
char[] num = "123456789".toCharArray();
char[] word = "abcdefghi".toCharArray();
new Thread(() -> {
synchronized (o){
for (char n : num) {
System.out.print(n);
try {
o.notify();
o.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
o.notify();
}
},"t1").start();
new Thread(() -> {
synchronized (o){
for (char w : word) {
System.out.print(w);
try {
o.notify();
o.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
o.notify();
}
},"t2").start();
}
version 3.0使用的是synchronized关键字、notify()和wait()方法进行线程的之间的通信;这个概念也就就是文章开始第三个问题回答的代码展示;程序开始,调度器先进行线程t1的调度,t1拿到了对象o这把锁,然后打印了一个数字,接下来,o对等待的线程进行唤醒,也就是t2,因为现在只有两个我们定义的线程和守护线程;唤醒t2之后,然后t1释放锁进入等待状态,锁一旦被释放,那么t2这个时候又拿到了这把锁,执行代码,打印一个字符之后,然后它再唤醒t1线程,并释放锁进入等待,t1拿到锁……重复上述步骤,要注意的是:打印完成之后,必然是有一个线程处于等待状态的,我们必须将这个等待的线程进行唤醒,如果不进行这步操作,该程序将会进入死循环,由于我们不知道是哪个线程最后处于等待状态,我们对两个线程中的循环结束之后都进行唤醒操作就可以了。这里还有一个点:我们并不能控制CPU去调度哪一个线程,也存在CPU一上来就去调度t2线程的可能性(一般,一般情况下是调用在前面启动的线程);如果是这样的话,那么打印出来的结果就和我们需要的结果的先后顺序是相反的。
如果我们调换线程启动的顺序,那么最终得到的结果又会改成a1b2c3……(一般情况),针对这种情况,我们使用一个类CountDownLatch
version 3.1 CountDownLatch
private static CountDownLatch latch = new CountDownLatch(1);
public static void main(String[] args) {
final Object o = new Object();
char[] num = "123456789".toCharArray();
char[] word = "abcdefghi".toCharArray();
new Thread(() -> {
synchronized (o){
for (char n : num){
System.out.print(n);
latch.countDown();
try {
o.notify();
o.wait();
}catch (InterruptedException e) {
e.printStackTrace();
}
}
o.notify();
}
},"t1").start();
new Thread(() -> {
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o){
for (char w : word) {
System.out.print(w);
try {
o.notify();
o.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
o.notify();
}
},"t2").start();
}
Java的concurrent包里面的CountDownLatch其实可以把它看作一个计数器,只不过这个计数器的操作是原子操作,同时只能有一个线程去操作这个计数器,也就是同时只能有一个线程去减这个计数器里面的值。
你可以向CountDownLatch对象设置一个初始的数字作为计数值,任何调用这个对象上的await()方法都会阻塞,直到这个计数器的计数值被其他的线程减为0为止。
无论CPU最开始调度的是t1还是t2,都可以保证线程t1总是在t2之前被调度;
如果t2先被调度,它会被阻塞住,因为计数器的值现在为1,然后CPU又去调度线程t1,在一次循环结束之后,“计数器”的值被调整为0,这个时候t2就可以被调度了。
version 4.0
public static void main(String[] args) {
char[] num = "123456789".toCharArray();
char[] word = "abcdefghi".toCharArray();
Lock lock = new ReentrantLock();
Condition condition_t1 = lock.newCondition();
Condition condition_t2 = lock.newCondition();
new Thread(() -> {
try {
lock.lock();
for (char n : num) {
System.out.print(n);
condition_t2.signal();
condition_t1.await();
}
condition_t2.signal();
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();
}
},"t1").start();
new Thread(() -> {
try {
lock.lock();
for (char w : word) {
System.out.print(w);
condition_t1.signal();
condition_t2.await();
}
condition_t1.signal();
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();
}
},"t2").start();
}
version 4.0中我们使用到了一个类ReentrantLock,也叫可重入锁。
使用ReentrantLock的格式和synchronized是非常相似的,但是两者又有区别
ReetrantLock | synchronized | |
是否为独占锁 | 是 | 是 |
加/解锁 | 手动 | 自动 |
响应中断 | 可以响应中断 | 不可响应中断 |
是否可重入 | 可重入 | 可重入 |
锁是否公平 | 可实现公平锁机制(谁等的时间久谁拿) | 非公平锁机制 |
上面在代码在实现上使用了lock对象获取了两个condition对象,还是一个执行完毕之后通过“唤醒”另一个线程,并让当前线程进入等待的执行逻辑,和version 3.0基本是一样的。但依然存在线程执行顺序的问题,因为一开始并不知道哪个线程会先被执行,所以推荐使用的version 1.0 / 2.0 / 2.1 / 3.1
无锁、偏向锁、轻量级锁和重量级锁的升级过程
偏向锁:严格来讲的,偏向锁其实并不能算是锁,它更像是一个“标识”,还是以上厕所为例,上厕所的时候,我在门上写了一个纸条“有人”,这个纸条就相当于偏向锁。
而一旦另一个人来上厕所,看见门上贴着“有人”的纸条,这个时候我就需要将纸条换成锁了(别问我是怎么提上裤子出来换的),因为如果不换的话,那个人就可能会直接打开门进来,这样就会造成不好的结果了,哈哈哈哈。
那么我加锁的过程中,也就是偏向锁升级为轻量级锁的过程。而它升级的过程也可以简单总结为:有竞争就升级。
如果来的人特别多,这个时候我就需要质量更好的锁(为什么需要质量好的锁,因为我怕他们破门而入),也就是将偏向锁升级为重量级锁的过程。
那什么时候轻量级锁会升级为重量级锁呢?
轻量级锁升级的为重量级锁也是因为线程之间的竞争,自旋时间过长,也就是当竞争大了之后,就不得不将锁升级,这样才能保证数据的安全;
通俗的说,门口没人加偏向锁;门口有四五个人等待,加轻量级锁;门口几百个人等着上厕所就需要加上重量级锁了,而也不会一直让他们(线程)在门口等着,如果都在门口等着,是极其消耗CPU资源的。所以会将他们(线程)安排到队列中等待,这样就不用占用CPU资源了。
偏向锁、轻量级锁和重量级锁的比较
锁 | 优点 | 缺点 | 适用场景 |
偏向锁 | 加锁和解锁都不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 只有一个线程访问同步块 |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗CPU | 追求响应时间,同步块执行速度非常块 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间慢 | 追求吞吐量,同步块执行时间较长 |