10.杨明翰的Java教学系列之初级多线程篇

文章目录

  • 复习
  • 前言
  • 并行与并发
  • 线程与进程
    • 进程(Process)
    • 线程(Thread)
  • 多线程的优劣势
  • `多线程的应用场景`
  • 内存模型
    • 1.计算机的内存模型
    • 2.java的内存模型
    • 3.内存交互
      • 3.1交互操作
        • 借(从主内存中借)
        • 还(从工作内存中还)
      • 3.2交互规则
    • 4.先行发生原则
    • 5.三个特征
      • 5.1原子性(Atomicity)
      • 5.2可见性(Visibility)
      • 5.3有序性(Ordering)
  • 线程的创建与启动
    • 实现Runnable接口
    • 实现Callable接口
    • 继承Thread类
  • 线程状态与生命周期
  • 线程的控制
    • `join加入`
    • `sleep休眠`
    • yield让步
    • priority优先级
    • suspend挂起
    • daemon后台&守护线程
  • `线程安全`
    • 1.互斥同步&阻塞同步(重量级)
      • 1.1 Synchronized锁
        • `可重入性`
      • 1.2 Lock锁
    • 2.非阻塞同步(轻量级)
      • 2.1 原子类
      • 2.2 volatile关键字
    • 3.无同步
      • ThreadLocal
      • 无共享数据
  • 线程的通信
    • wait、notify、notifyAll
    • Condition
    • BlockingQueue
  • 总结
  • 作业


复习

1.使用接口的好处是什么?
2.Java的工作机制?
3.什么是方法重写与方法重载?
4.什么是Java三要素?
5.如何最高效的遍历Map?
6.多态的三个必要条件?
7.HashTable与HashMap的区别?
8.LinkedList与ArrayList的区别?
9.集合框架的继承体系?
10.JDK&JRE&JVM分别是什么以及他们的关系?

前文链接
1.偏头痛杨的Java入门教学系列之认识Java篇
http://blog.csdn.net/piantoutongyang/article/details/70138697
2.偏头痛杨的Java入门教学系列之变量&数据类型篇
http://blog.csdn.net/piantoutongyang/article/details/70193622
3.偏头痛杨的Java入门教学系列之表达式&运算符&关键字&标识符&表达式篇
http://blog.csdn.net/piantoutongyang/article/details/71027446
4.偏头痛杨的Java入门教学系列之初级面向对象篇
http://blog.csdn.net/piantoutongyang/article/details/78135129
5.偏头痛杨的Java入门教学系列之流程控制语句篇
http://blog.csdn.net/piantoutongyang/article/details/71698589
6.偏头痛杨的Java入门教学系列之数组篇
http://blog.csdn.net/piantoutongyang/article/details/72510787
7.偏头痛杨的Java入门教学系列之进阶面向对象篇
http://blog.csdn.net/piantoutongyang/article/details/73176373
8.偏头痛杨的Java入门教学系列之异常篇
https://blog.csdn.net/piantoutongyang/article/details/88094933
9.偏头痛杨的Java入门教学系列之初级集合框架篇
http://blog.csdn.net/piantoutongyang/article/details/74058239


前言

单线程往往是一条逻辑执行流,从上到下的的执行,如果在执行过程中遇到了阻塞,
那么程序会停止在原地,我们使用debug模式时可以看到这种情况,但单线程的能力有限。
试想一下,如果tomcat是单线程的话,那么高并发就无从谈起了对吗。

多线程就是多个逻辑执行流在执行,多个执行流之间可以保持独立。

可以理解成:单线程就是餐厅里只有一个服务员,多线程就是餐厅里有多个服务员。
在顾客比较多的时候一个服务员肯定是忙不过来的,那么多线程就派上用场了。

需要注意的是多线程也是需要消耗资源的(例如CPU、内存),是空间换时间的一种玩法,
如果服务端的内存和CPU的使用率都很低,那么使用多线程会是一种提升效率的好方法,
是否需要使用多线程、需要多少线程需要自行判断。

随着多核处理器越来越便宜与系统资源的低利用率,我们需要利用多线程来大幅提升程序的性能。
多线程虽然好用,但里面的坑也不少,并且很多多线程的问题和bug并不是每次都能复现,
很有可能成为所谓的灵异事件,因此,掌握多线程与java并发的知识就显得尤为重要。

其实,多线程无处不在,即使我们没有显式的编写多线程的代码,
多线程也隐式的存在于我们的代码中,
例如:各种开源框架、servlet、tomcat、jvm的gc回收等等。

计划把java多线程&并发这块的写成的那写成一个小系列,
因此本文中没有出现的知识点会在后续文章中出现,
例如线程池、线程组、concurrent包、锁机制等。


并行与并发

一些同学容易混淆这两个概念,并发(concurrency)与并行(parallel)是有区别的。
并行是指在同一时刻有多条指令在多个CPU(或多核CPU)上同时执行。
并发是指在同一时刻只能有一条执行在CPU上执行,但多条指令被快速切换执行(时间分片),
感觉上好像是多个指令在同时执行,但同时执行的指令只有一个。

10.杨明翰的Java教学系列之初级多线程篇_第1张图片


线程与进程

我们可以简单的理解操作系统(windows、linux、mac)里的一个程序在开始运行(入驻内存)后,
就成为了一个进程。每个运行中的程序就是一个进程,例如:微信、QQ、暴风影音、浏览器等等。
当一个程序运行时,程序内部可能包含了多个逻辑执行流,每个执行流就是一个线程。
线程与进程是包含的关系。一个进程至少包含一个线程,至多可以包含n个线程,
一个线程必须从属于一个进程。

进程(Process)

进程是操作系统中独立存在的实体,拥有独立的系统资源(内存、文件句柄、安全证书等),
进程之间不共享内存,进程之间的通信较困难。
进程是程序的一种动态形式,是cpu,内存等资源占用的基本单位,
而线程是不能独立的占有这些资源的。

线程(Thread)

线程是进程中某个单一顺序的控制流,线程是进程的基本执行单位。
当进程被初始化后,主线程就被创建了。
线程可以拥有自己的栈、程序计数器(Program Counter)、局部变量等,
同一进程下的多个线程共享该进程所拥有的全部资源。
线程的执行是抢占式的,相同进程下的多个线程可以并发执行并相互通信。


多线程的优劣势

优势:
有效提升资源利用率以及程序的性能、吞吐量、响应速度,利用多核处理器彰显巨大威力。

劣势:
线程之间的切换带来额外的性能开销,多线程基础知识掌握不好的话,会编写出灵异事件的bug。
例如:多线程的安全性、死锁等问题,并且由于多线程的执行不确定性和随机性,
导致分析问题难度增加。


多线程的应用场景

有很多同学会吐槽说,多线程学完了发现并没有什么用武之地嘛,那可就大错特错了。
我们每天都在接触多线程,只不过是自己不知道而已,例如web服务器的请求就是多线程的。
这里又罗列出一些多线程的场景,以供大家参考。

1.异步处理&非阻塞
可以把占据长时间的程序中的任务放到新线程去处理,缩短响应时间;
在I/O阻塞时,程序可以用另一个线程去做别的事情而并非一直傻傻的在等待I/O返回;

2.定时向大量(例如100万以上)的用户发送邮件&消息&信息;
3.统计分析的业务场景,让每个线程去统计一个部门的某类信息;
4.后台进程,例如GC线程;
5.多线程操作文件,提高程序执行时间;

下面这段引用自网络:
假设有一个请求需要执行3个很缓慢的io操作(比如数据库查询或文件查询),
那么正常的数据可能是:

a.读取文件1(10ms)
b.处理1的数据(1ms)
c.读取文件2(10ms)
d.处理2的数据(1ms)
e.读取文件3(10ms)
f.处理3的数据(1ms)
g.整合1,2,3的数据结果(1ms)
单线程总共需要34ms,但如果你把ab,cd,ef分别分给3个线程去做,就只需要12ms了。

再假设
a.读取文件1(1ms)
b.处理1的数据(1ms)
c.读取文件2(1ms)
d.处理2的数据(1ms)
e.读取文件3(28ms)
f.处理3的数据(1ms)
g.整合1,2,3的数据结果(1ms)

单线程总共需要34ms,如果还是按照上面的划分方案,类似于木桶原理,
速度取决于最慢的那个线程。在这个例子里,第三个线程执行了29ms,
那么最后这个请求的耗时是30ms,比起不用单线程,就节省了4ms,
但有可能线程调度切换也要花个1-2ms,因此这个方案显示的优势就不明显了,
还带来了程序复杂性的提升,不值得。

所以我们要优化文件3的读取速度,可以采用缓存,减少一些重复读取,
假设所有用户都请求这个请求,相当于所有的用户都需要读取文件3,那你想想,
100个人进行了这个请求,相当于你花在读取这个文件上的时间久是28*100 = 2800ms,
如果你把这个文件缓存起来,那只要第一个用户的请求读取了,第二个用户不需要读取了,
从内存读取是很快的,可能1ms都不到。


内存模型

在继续往下了解多线程之前,我们很有必要先要了解一下内存模型相关的概念,
了解这些知识有助于让我们更好的理解多线程里的一些特性和底层原理,让我们的步伐更扎实。
至于“内存模型”这四个字,可以理解成对内存操作过程的一种抽象。
说白了:java内存是怎么"玩"的?这个"玩"的过程用一些文字和图片表示出来。

1.计算机的内存模型

在了解java内存模型之前,我们需要先了解一下计算机内存模型,循序渐进,更容易理解后面的知识。
在我之前的一篇文章:
《偏头痛杨的程序员应该知道的一些计算机&开发&网络基础知识》
https://blog.csdn.net/piantoutongyang/article/details/89071396
中有介绍过一些计算机理论知识,其中里面有讲到过CPU、寄存器、CPU高速缓存、内存等知识点。

在这里简短的赘述一下,细节可以看一眼那篇文章。
CPU在运行时需要与内存进行读写操作,但内存对于CPU来说实在是太慢了,
于是引入了CPU高速缓存(简称缓存)来加快运行速度,将CPU要使用的数据从内存复制到缓存里,
CPU直接去读写缓存中的数据,当CPU处理结束后,再把数据从缓存同步到内存中。

多核CPU中每个核都对应一个自己的缓存,而多个缓存又会共享同一个主内存(简称主存)。
那在多核CPU并发操作同一块主存数据时就会产生缓存不一致的问题,
就好像每个缓存里对于一个变量存的数据都不一样,那么以哪个缓存里的数据为基准呢?

那么每个核在对缓存进行操作时都需要遵守一些协议,来保证不会出现缓存一致性问题。
具体是什么协议不再展开。
10.杨明翰的Java教学系列之初级多线程篇_第2张图片

(不同架构的计算机会有不一样的内存模型,
此外这种缓存一致性问题在分布式缓存中也会有所体现,正所谓知识是相通的。)

2.java的内存模型

不止计算机有内存模型,java也有自己的内存模型,简称:JMM(Java Memory Model)。
JMM规定所有的变量(包括实例变量、类&静态变量,不包括局部变量和形参,因为后者是线程私有,不存在共享&竞争问题)都必须存储在主内存(main memory)中,
每个线程都有自己的工作内存(working memory),
工作内存会从主内存中复制一些自己要用到的变量,形成一个变量副本存储在自己的工作内存中。

线程对变量的读写都必须在工作内存中完成,不能直接去读写主内存,
每个线程之间也无法直接去读写对方的工作内存,必须通过主内存来完成。
10.杨明翰的Java教学系列之初级多线程篇_第3张图片

3.内存交互

JMM定义了8种操作来完成主内存与工作内存之间的交互,每一种操作都是原子的。
我们可以把这8种操作来分成4类,有助于结构化记忆。

3.1交互操作

锁定(lock):将主内存变量标识为某个线程独享的状态。
解锁(unlock):将处于锁定状态的主内存变量释放,释放后的变量才能被其他线程锁定。

借(从主内存中借)

读取(read):将主内存变量的值从主内存传递到工作内存中,供load操作使用。
载入(load):将read操作从主内存传递过来的值复制到工作内存变量中。

使用(use):将工作内存变量中的值传递给执行引擎,在使用该变量时触发。
赋值(assign):将工作内存变量赋值。

还(从工作内存中还)

存储(store):将工作内存变量的值传递到主内存中,供write操作使用。
写入(write):将store操作从工作内存传递过来的值覆盖到主内存变量中。

3.2交互规则

  1. 如果要把变量从主内存中复制到工作内存中,必须先执行read再执行load。
  2. 如果要把变量从工作内存中复制到主内存中,必须先执行store再执行write。
  3. read与load、store与write必须成对出现,不允许单独使用。
  4. 工作内存变量改变后必须立刻同步回主内存,用于保证其他线程可以看到自己的修改。
  5. 如果没有发生任何操作,不允许工作内存变量同步回主内存。
  6. 只能在主内存中创建新对象,不允许在工作内存自己创建对象。
  7. 在同一时间只允许一个线程对变量进行lock操作,其他线程必须等待,但允许重复执行lock。
  8. lock后会清空工作内存变量的值,需要重新执行load和assign。
  9. 不允许对没有lock的变量使用unlock,也不允许unlock其他线程lock住的变量。
  10. 执行unlock之前必须先把工作内存变量同步回主内存中。

每次使用工作内存变量前都必须先从主内存中获取最新的值,
用于保证可以看到其他线程对变量所修改的值。

4.先行发生原则

5.三个特征

5.1原子性(Atomicity)

java内存模型中的那些操作都具有原子性,但如果需要一个更大的原子性范围,
则需要使用lock和unlock,但这两个操作并没有直接开放给我们,
但却提供了字节码指令monitorenter和monitorexit来隐式使用上面的操作,
这就是synchronized锁的底层。

5.2可见性(Visibility)

5.3有序性(Ordering)

先介绍一个概念:指令重排
计算机CPU为了优化效率可能会对指令进行乱序执行,CPU运算结束后会把结果进行重组以保证顺序性。

但CPU不能保证代码的编写顺序与执行顺序是一致的。类似的,java的JVM中也会有这种指令重排。


线程的创建与启动

有三种方式创建&启动线程,分别为:Runnable、Callable、Thread。
注意:在执行main()时候,其实main()本身是要启动一个main线程的,也叫主线程。
主线程与我们自己新建的线程是截然不同的线程,请不要混淆。

实现Runnable接口

实现了Runnable接口的类在使用多线程时,可以更方便的访问共享实例变量。

public class RunnableDemo1 {
	public static void main(String[] args) {
		RunnableDemo r1 = new RunnableDemo();
		Thread t1 = new Thread(r1);
		Thread t2 = new Thread(r1);

		t1.start();
		t2.start();
	}
}

class RunnableDemo implements Runnable {
	 int i = 0;
	/**
	 * 线程要执行的代码
	 */
	public void run() {
		for (; i < 10; i++) {
			// Thread.currentThread().getName()可以获取当前线程名称
			System.out.println(Thread.currentThread().getName() + "==>" + i);
		}
	}
}

实现Callable接口

jdk1.5才出现的接口,可以视为Runnable的升级版,主要用于方便获取线程的返回值与异常。

public class CallableDemo1 {
	public static void main(String[] args) {
		//注意Callable需要泛型支持
		FutureTask<Boolean> ft1 = new FutureTask<>(new CallableDemo(1));
		FutureTask<Boolean> ft2 = new FutureTask<>(new CallableDemo(0));
		FutureTask<Boolean> ft3 = new FutureTask<>(new CallableDemo(2));
		Thread t1 = new Thread(ft1);
		Thread t2 = new Thread(ft2);
		Thread t3 = new Thread(ft3);

		t1.start();
		t2.start();
		t3.start();

		// 获取线程返回值
		try {
			//get方法获取返回值时候会导致主线程阻塞,直到call()结束并返回为止
			System.out.println(ft1.get());
			System.out.println(ft2.get());
			System.out.println(ft3.get());
		} catch (Exception e) {
			//可以catch住线程体里的异常
			e.printStackTrace();
		}
	}
}

class CallableDemo implements Callable<Boolean> {
	int flag;
	int i = 0;

	public CallableDemo(int flag) {
		this.flag = flag;
	}

	/**
	 * 线程要执行的代码
	 */
	public Boolean call() throws Exception {
		for (; i < 10; i++) {
			// Thread.currentThread().getName()可以获取当前线程名称
			System.out.println(Thread.currentThread().getName() + "==>" + i);
		}

		if (flag == 1) {
			return true;
		} else if(flag == 0){
			return false;
		}else {
			throw new Exception("哇咔咔咔");
		}
	}
}

继承Thread类

继承Thread就不能继承其他类,而Callable、Runnable接口可以。
继承Thread相对于实现接口而言,不能共享实例变量,使用线程的方法更加方便,
例如:获取线程的id,线程名,线程状态等。

public class ThreadDemo1 {
	public static void main(String[] args) {
		//创建三个线程对象
		ThreadDemo t1 = new ThreadDemo();
		ThreadDemo t2 = new ThreadDemo();
		ThreadDemo t3 = new ThreadDemo();
		//启动三个线程对象
		t1.start();
		t2.start();
		t3.start();
	}
}

class ThreadDemo extends Thread{
	/**
	 * 线程要执行的代码
	 */
	public void run() {
		for(int i = 0 ; i < 10 ; i ++) {
			//this.getName()可以获取当前线程名称
			System.out.println(this.getName()+"==>"+i);
		}
	}
}

线程状态与生命周期

首先JAVA在JDK1.5之后的Thread类有6种状态(看了JDK源码),网上很多文章写的是5种,
区别在于其中RUNNABLE包含了原来的RUNNABLE和RUNNING,
原来的BLOCKED分解成:BLOCKED、WAITING、TIMED_WAITING。
每个线程在同一时间只能有一种状态,这6种状态是JAVA的线程状态而非操作系统的线程状态。

线程被创建并启动后,不会一直霸占着CPU独自运行,CPU需要在多线程中切换,
线程的执行策略是抢占式的(也依赖于线程优先级),线程状态也会在运行与阻塞中不断切换。

名称 描述
NEW(新建&初始) 使用new()创建一个线程后,该线程属于新建状态,此时初始化成员变量,分配线程所需要的资源,但不会执行线程体。
RUNNABLE(运行) 该状态包含了RUNNING(运行中)与READY(就绪),调用start()启动线程后,该线程属于可运行状态,线程的运行需要依赖于CPU的调度,具有随机性,获得CPU调度后线程状态变成了RUNNING,开始执行线程体。
BLOCKED(阻塞) 当前线程在等待其他线程synchronized锁释放时,会进入阻塞状态。等到其他线程释放synchronized锁时,当前线程进入可运行状态。多线程情况下为了保证线程同步会使用synchronized锁机制。
WAITING(等待) 当前线程等待其他线程执行操作。
TIMED_WAITING(计时等待) 当前线程在一定时间范围内等待其他线程执行操作。
TERMINATED(终止&死亡) 线程终止状态,线程终止之后不可再调用start(),否则将抛异常。

根据上面的线程状态,就可以推出线程的生命周期,就是一个线程从出生到死亡的过程。

10.杨明翰的Java教学系列之初级多线程篇_第4张图片
图片参考《Java并发编程的艺术》


线程的控制

JDK通过提供一些方法和策略来控制线程的执行。

join加入

让线程A等线程B完成之后再执行,我们可以在线程A中使用线程B的join(),此时线程A将阻塞,
直到线程B执行完再恢复执行。
我们可以将大问题划分成许多个小问题,再为每个小问题分配一个线程,当所有的小问题都完成后,
再调用主线程来接着往下走。

public class JoinDemo1 {
	public static void main(String[] args) throws Exception {
		System.out.println("start");
		JoinThread r = new JoinThread();
		
		Thread t1 = new Thread(r);
		Thread t2 = new Thread(r);
		
		t1.start();
		t2.start();
		
		t1.join();
		t2.join();
		//主线程等待t1,t2两个线程都执行完毕再继续往下走。
                //此处t1,t2会并发执行,并不会因为t1.join()先调用就执行完t1再执行t2,
		//join()方法也可以加入超时时间,如果超过时间则不再等待
		
		System.out.println("done");
	}
}

class JoinThread implements Runnable {
	public void run() {
		for (int i = 0; i < 100; i++) {
			System.out.println(Thread.currentThread().getName()+" "+i);
		}
	}
}

在这个例子中,如果没有加join(),则在t1,t2两个线程还没有执行完就会打印出done,
因为线程是争抢执行的,主线程开完2个子线程后就直接走到done了,
2个子线程的执行相当于是异步,主线程不会等待t1,t2两个线程执行完毕再执行。

需要注意,在本例中,t1,t2会是并发执行,而非t1走完再走t2。
至于为什么t1,t2是并发执行而不是串行,我想应该作者就是想这么设计的,
因为如果把join设计成串行执行,那效率会大打折扣,相当于没有用到多线程并发。

sleep休眠

Thread.sleep()可以让正在执行的线程暂停若干毫秒,并进入等待状态,时间到了之后自动恢复。
在休眠时间范围内即使当前没有任何可执行的线程,休眠中的线程也不会被执行。
sleep()不释放对象锁,如果当前线程持有synchronized锁并sleep(),则其他线程仍不能访问。

public class DaemonDemo1 {
	public static void main(String[] args) {
		for (int i = 0; i < 10; i++) {
			if (i == 5) {
				try {
					//暂停5秒
					Thread.sleep(5 * 1000);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
			System.out.println(Thread.currentThread().getName() + " " + i);
		}
	}
}

yield让步

Thread.yield()使当前线程放弃CPU调度,但是不使线程阻塞&等待,即线程仍处于可执行状态,
随时可能再次获取CPU调度。相当于认为当前线程已执行了足够的时间从而转到另一个线程。
也有可能出现刚调用完Thread.yield()放弃CPU调度,当前线程立刻又获得CPU调度。

public class YieldDemo1 {
	public static void main(String[] args) {
		YieldThread r = new YieldThread();
		new Thread(r).start();
		new Thread(r).start();
	}
}

class YieldThread implements Runnable {
	public void run() {
		for (int i = 0; i < 100; i++) {
			if(i==50) {
				Thread.yield();
			}
			System.out.println(Thread.currentThread().getName()+" "+i);
		}
	}
}

priority优先级

所谓优先级就是谁先执行谁后执行的问题,每个线程在运行时都具有一定的优先级,
优先级高的线程具有较多的执行机会,优先级低的线程具有较少的执行机会。
每个线程的默认优先级与创建它的父线程的优先级相同。优先级范围只能是1-10之间。

setPriority()可以传入一个正整数作为参数,但一般建议使用Thread的常量来设置优先级:
Thread.MAX_PRIORITY=10
Thread.NORM_PRIORITY=5
Thread.MIN_PRIORITY=1

public class PriorityDemo {
	public static void main(String[] args) {
		PriorityThread t1 = new PriorityThread();
		PriorityThread t2 = new PriorityThread();
		
		t1.setPriority(Thread.MAX_PRIORITY);
		t2.setPriority(Thread.MIN_PRIORITY);
		
		t1.start();
		t2.start();
                System.out.println("得到线程优先级t1="+t1.getPriority());
		System.out.println("得到线程优先级t2="+t2.getPriority());
	}
}
class PriorityThread extends Thread{
	public void run(){
		for(int i = 0 ; i < 100 ; i ++){
			System.out.println(" "+getName()+":"+i);
		}
	}
}

suspend挂起

suspend()和resume()配套使用,suspend()使得线程进入阻塞状态,并且不会自动恢复,
必须其对应的resume()被调用,才能使得线程重新进入可执行状态。
因为不建议使用,所以本文不再讲解。

daemon后台&守护线程

后台线程是指在后台运行的线程,为其他线程提供服务,JVM的GC就是后台线程。
如果前台线程全部死亡,后台线程会自动死亡。
前台线程创建的子线程默认是前台线程,后台线程创建的子线程默认是后台线程。
把某线程设置为后台线程的操作必须在线程启动之前,否则会抛异常。

public class DaemonDemo1 {
	public static void main(String[] args) {
		Thread t1 = new Thread(new DaemonThread());
		//设置为后台线程
		t1.setDaemon(true);
		t1.start();
		for (int i = 0; i < 10; i++) {
			System.out.println(Thread.currentThread().getName()+" "+i);
		}
		//后台线程还没有完全运行完就会死掉,因为主线程先死了。
		//查看是否为后台线程
		System.out.println(t1.isDaemon());
		System.out.println(Thread.currentThread().isDaemon());
	}
}
class DaemonThread implements Runnable{
	public void run() {
		for (int i = 0; i < 100; i++) {
			System.out.println(Thread.currentThread().getName()+" "+i);
		}
	}
}

线程安全

线程的安全应该是多线程基础概念中最最最重要的知识点了,必须要掌握。

众所周知,多线程是由CPU调度来获取执行且具有随机性与争抢性(还有指令重排),
而多个线程又共享进程的内存,可以同时读写共享内存中堆上的对象数据,
很有可能某一线程读写了其他线程正在读写的对象&数据。

使用多线程访问一个共享对象&数据时,如果没有相应的机制来协同访问,
可能造成运算结果错误&异常,数据状态不稳定,即线程安全问题。

举个栗子:
某商城系统中某件商品的库存是1,代码中判断如果库存=0就不能再购买了,
如果库存>0则能购买,购买成功后,库存减1,在单线程时代这个逻辑是没有问题的。
但如果此时并发来了若干个线程,大家都要去买这个商品,这时运算的结果可能出乎我们的意料。
有可能若干个线程同时进入判断库存>0,都是判断通过,进而大家都购买成功,造成库存为负数。

(库存一致性问题也可以使用其他的解决方案,例如CAS、数据库的锁机制,分布式锁等等)

库存出现负数表示线程不安全,那我们需要使用一种安全的机制,那么是什么机制呢?
有很多人会把"线程安全"和"线程同步"这两个概念弄混淆,甚至认为这2个概念是相同的,其实不然。

线程同步是线程安全的一种实现方式而已,但线程安全不止一种实现方式,
保障线程安全的实现机制有:互斥同步&阻塞同步、非阻塞同步、无同步。

1.互斥同步&阻塞同步(重量级)

1.1 Synchronized锁

让线程A先执行完把库存扣成0,接着再让线程B执行,以此类推,一个线程执行,其他线程阻塞。
这样库存就不会出现负数了,像有把锁一样,只有获得锁的线程才能执行,其他线程都得阻塞。
这样就是线程安全,线程安全需要使用多线程的同步机制。

线程同步是指多个线程同时访问某资源时,采用一系列的机制以保证同时只有一个线程访问该资源。
线程的同步用于线程共享数据,转换和控制线程的执行,保证内存的一致性。
多线程同步有几种方式实现,会在下面一一列举。

锁这个概念经常出现在计算机科学领域中,例如:mysql的锁、redis的锁、多线程的锁等等。
其实核心概念非常简单,就是线程A在执行某段代码前,
先获取一个锁(互斥锁,谁拿到谁执行,其他线程必须排队等待),在线程A没有执行完之前,
线程B也像获取这个锁从而执行这段代码,但因为线程A还没有释放锁,因此线程B只能等待,
等到线程A执行完代码释放了锁之后,线程B就可以获得锁从而执行这段代码,在线程B执行期间,
其他线程依然处于等待状态,相当于用锁来保护某段代码同一时间只能有一个线程可以执行。

synchronized关键字可以加在方法上与代码块上,
也可叫做synchronized方法(同步方法)与synchronized块(同步块)。

多线程并发情况下,synchronized关键字能够保证在同一时间只有一个线程执行某段代码,
而其他线程需要等待正在执行的线程执行完毕之后才有机会去执行。
而对于非synchronized关键字修饰的方法和代码块,其他线程均可畅通无阻的执行,从而导致线程安全问题的发生。

这里有个重要的概念:监视器(monitor)。
java中的每一个对象引用都有一个监视器,用来检测并发时的重入问题,在非多线程情况下,
监视器不起作用,在synchronized情况下监视器才起作用。

线程开始执行synchronized之前,会自动获得对象引用的监视器的锁,简称监视器锁(又叫内置锁、内部锁),
或者也可以说每个java对象引用都可以用来做一个实现同步的监视器锁。
线程在执行完synchronized之后则自动释放监视器锁。

那么是哪个对象引用呢?

  1. 如果使用synchronized块就是传入参数的对象引用;
  2. 如果使用synchronized实例方法就是this对象引用&调用该方法的对象引用;
  3. 如果使用synchronized静态方法相当于类本身(Class对象),该类的所有对象引用共享一把锁;

(synchronized块相比较而言可以更加精准的控制要加锁的范围,灵活性较高)

在synchronized情况下,任何时刻只能有一个线程可以获取监视器锁,
而其他未获得锁的线程只能阻塞,等到那个线程放弃监视器的锁,这个线程才能获取,进而执行。
被synchronized包含的区域被也被称为临界区(critical section),同一时间内只有一个线程处于临界区内,保证了线程的安全。

例子1

public class SynchronizedDemo0 implements Runnable {
	public void run() {
		synchronized(this) {
			//this代表SynchronizedDemo0对象
			for(int i = 0; i<3; i++) {
				//模拟执行动作
				System.out.println(Thread.currentThread().getName()+" "+i);
			}
		}
	}
	
	public static void main(String[] args) {
		//只new了一个SynchronizedDemo0对象,让三个线程来共享,从而造成同步执行。
		SynchronizedDemo0 s = new SynchronizedDemo0();
		//新建三个线程
		Thread t1 = new Thread(s);
		Thread t2 = new Thread(s);
		Thread t3 = new Thread(s);
		t1.start();
		t2.start();
		t3.start();
	}
}

例子2

public class SynchronizedDemo1 {
	public static void main(String[] args) throws Exception {
		Item item1 = new Item();
		item1.count = 1;
		item1.name = "java编程思想"; 
		
		SynchronizedThread r1 = new SynchronizedThread(item1);
		// 开启10个线程来购买商品
		for (int i = 0; i < 10; i++) {
			new Thread(r1).start();
		}

		Thread.sleep(1 * 1000);
		System.out.println(item1.count);
	}
}

/**
 * 模拟商品
 */
class Item {
	// 商品名称
	String name;
	// 商品库存
	Integer count;
}
/**
 * 模拟购买线程
 */
class SynchronizedThread implements Runnable {
	private Item item;

	public SynchronizedThread(Item item) {
		this.item = item;
	}

	public void run() {
                //每个线程都要先获取item对象的监视器的锁,才能进入。
		synchronized (item) {
			if (item.count > 0) {
				System.out.println("购买成功");
				item.count--;
			} else {
				System.out.println("购买失败,库存不足");
			}
		}
	}
}

注意事项:
很多同学只知道加上synchronized代表同步,程序员想不出现库存为负数的情况。
于是就在service层的方法上加synchronized,殊不知这样会带来大问题。
我们一般会把controller、service、dao这三层类的对象设置为单例模式(spring默认),
这就相当于把锁的粒度放在了service层,会导致所有的商品在购买时全部阻塞,造成性能瓶颈。
正确的做法是我们只需要把锁的粒度放在商品对象上即可,即监视器为商品对象,
这样只有并发线程对同一个商品对象操作的时候才会上锁,而不是两个不同的商品来的并发也阻塞。
这样就保证了并发与线程安全,记得要重写商品对象的equals()与hashcode()。
虽然JAVA允许使用任何对象的监视器来获得锁,但我们应该使用可能被并发访问的共享对象。

持有锁的线程执行完同步块代码,锁就释放了,释放出来的锁会被其他线程争抢,
一旦被某线程抢到锁后,没抢到锁的线程只能被阻塞,等待锁释放。

什么时候当前线程会释放监视器的锁?
1.当synchronized块&方法执行完毕;
2.当synchronized块&方法执行中使用break&return跳出来时;
3.当synchronized块&方法执行中遇到exception或error跳出来时;
4.当synchronized块&方法执行中使用了监视器所属对象的wait()时;

什么时候当前线程不会释放监视器的锁?
Thread.sleep()、Thread.yield()、Thread.suspend()。

可重入性

synchronized锁具有可重入性的,那什么是可重入性呢?
一般来讲,当线程A持锁,线程B想获锁时会发生等待,但如果是线程A自己再次调用获锁时,
就会调用成功,允许线程再次获得自己已经持有的锁。

重入性的实现为:监视器为每个锁关联一个“获取计数值”和“所有者线程”。
当计数值为0时,则认为当前锁没有被任何线程所持有。
当某线程获取一个未被持有的锁时,监视器记录锁的持有者以及让计数值+1,
如果当前线程再次获取这个锁时,计数值再+1,当线程退出synchronized块后,计数值-1,
当计数值为0时,表示当前锁被释放。

如果没有可重入性,则下面会造成线程死锁。

public class SynchronizedDemo3 {
	public static void main(String[] args) {
		SynchronizedDemo3Father f = new SynchronizedDemo3Son();
		f.eat();
	}
}

class SynchronizedDemo3Father {
	public synchronized void eat() {
		System.out.println("我是爸爸");
	}
}

class SynchronizedDemo3Son extends SynchronizedDemo3Father {
	public synchronized void eat() {
		System.out.println("我是儿子");
		super.eat();
	}
}

对于重入概念的了解,为下面的可重入锁(ReentrantLock)预热。

1.2 Lock锁

在JDK1.5开始,JAVA提供了一种更为强大的线程同步机制,通过显式定义同步锁对象来实现同步。
lock锁比synchronized锁的锁定操作更多,lock锁允许更灵活的加锁结构,并支持condition对象。
与synchronized锁相似,每次只能有一个线程对lock对象加锁,
线程访问共享数据前必须先获得lock对象,使用lock对象可以显式的加锁、释放锁。

Lock与ReadWriteLock(读写锁)是JDK1.5提供的两个根基接口,
其中ReadWriteLock允许对共享资源的并发访问。

Lock的实现类是ReentrantLock(可重入锁)。
ReadWriteLock的实现类是ReentrantReadWriteLock(可重入读写锁)。

ReentrantLock(可重入锁)具有可重入性,一个线程可以对已被加锁的ReentrantLock锁再次加锁,
ReentrantLock对象会维持一个计数器来追踪lock()方法的嵌套调用,线程在每次调用lock()加锁后,
必须显式的调用unlock()来释放锁,一段被锁保护的代码可以调用另一段被锁保护的代码。

public class LockDemo1 {
	// 定义lock锁对象,每个lock对象对应一个要加锁的对象,能达到synchronized的效果。
	private final ReentrantLock lock = new ReentrantLock();

	public void doSomething1() {
		//开启锁
		lock.lock();
		System.out.println(Thread.currentThread().getName() + " do something1...");
	}

	public void doSomething2() {
		System.out.println(Thread.currentThread().getName() + " do something2...");
		// 释放锁,可以跨方法
		lock.unlock();
	}

	public static void main(String[] args) {
		LockDemo1 lockDemo1 = new LockDemo1();
		LockThread lockThread = new LockThread(lockDemo1);
		for (int i = 0; i < 20; i++) {
			new Thread(lockThread).start();
		}
	}
}

class LockThread implements Runnable {
	LockDemo1 lockDemo1;

	public LockThread(LockDemo1 lockDemo1) {
		this.lockDemo1 = lockDemo1;
	}

	public void run() {
		lockDemo1.doSomething1();
		lockDemo1.doSomething2();
	}
}


2.非阻塞同步(轻量级)

2.1 原子类

2.2 volatile关键字

在讲述volatile关键字之前,我们必须要掌握一些基础概念:
java内存模型、指令重排,以及衍生出来的“可见性”概念,在文章的前面已经有所讲述。

我们可以使用volatile关键字来修饰一个共享变量,被修饰的变量具有两种特性:

  1. 保证被修饰的变量对所有线程的可见性,当前线程修改变量的值后,其他线程立即可见,
    但普通变量则不能立即可见,因为普通线程需要主内存与工作内存的交互才能完成传递。
    使用工作内存变量前必须先从主内存中刷新,用于保证自己可以看到其他线程的修改。
    工作内存变量改变后必须立刻同步回主内存,用于保证其他线程可以看到自己的修改。

  2. 禁止指令重排,代码的顺序与执行的顺序相同,普通变量则会指令重排,导致执行顺序不可控。

volatile变量在并发下进行运算时,并不是安全的,因为运算这个过程并不是原子性操作。
例如:volatile int a,然后多线程i++,这个i++并不是原子性操作,因此不安全。

volatile关键字底层原理:
使用volatile关键字修饰后,在字节码指令层面会添加一个lock操作,
lock操作相当于一个内存屏障(memory barrier&fence),
执行指令重排时不能把下面的指令重排到内存屏障上面的位置。

lock操作使得当前cpu的高速缓存写入主存,同时使其他cpu的高速缓存失效,
因此可以让volatile变量的修改对其他CPU立即可见。

volatile操作读操作与普通变量区别不大,但是写操作会慢一些,因为需要插入一些内存屏障指令,
来保证没有指令重排。

3.无同步

ThreadLocal

严格意义上讲,ThreadLocal并不属于线程同步机制的一员,
但它可以从另一个维度来解决冲突访问的问题,
ThreadLocal与同步机制是相辅相成的。

ThreadLocal代表一个线程局部变量,是JAVA为线程安全提供的工具类。
通过把数据放在ThreadLocal中就可以让每个线程创建一个该变量的副本,
每个线程都可以独立的改变其变量副本中的值而不会和其他线程的副本冲突,
好像每一个线程都完全拥有该变量一样。从而避免并发访问的线程安全问题。

ThreadLocal可以简化多线程编程时的并发访问,可以很简洁的隔离多线程的竞争资源。
TheadLocal还可以携带共享资源跨越多个类与方法。

ThreadLocal与其他的同步机制类似,都是为了解决多线程对同一变量的访问冲突,
ThreadLocal不能代替同步机制,维度不一样,
同步机制是通过加锁的机制为了同步多个线程对相同资源的并发访问,在竞争状态下获得共享数据,
而ThreadLocal是为了隔离多个线程的数据共享,从根本上避免多个线程对共享资源的竞争。

此外ThreadLocal还有一个小功能,就是可以在一次线程调用中,可以跨类跨方法携带参数。

public class ThreadLocalDemo1 {
	public static void main(String[] args) {
		Person p = new Person();
		//启动两个线程,两个线程共享person对象
		new ThreadDemo1(p).start();
		new ThreadDemo2(p).start();
	}
}

class ThreadDemo1 extends Thread {
	Person p;

	public ThreadDemo1(Person p) {
		this.p = p;
	}

	public void run() {
		System.out.println(this.getName() + " start " + p.getName());
		// 将线程局部变量赋值
		p.setName("张三");
		System.out.println(this.getName() + " end " + p.getName());
	}
}

class ThreadDemo2 extends Thread {
	Person p;

	public ThreadDemo2(Person p) {
		this.p = p;
	}

	public void run() {
		System.out.println(this.getName() + " start " + p.getName());
		// 将线程局部变量赋值
		p.setName("李四");
		System.out.println(this.getName() + " end " + p.getName());
	}
}

class Person {
	// 定义线程局部变量,每个线程都会保留该变量的一个副本,多个线程之间并不互相印象。
	private ThreadLocal<String> name = new ThreadLocal<>();

	public String getName() {
		return this.name.get();
	}

	public void setName(String name) {
		this.name.set(name);
	}
}

无共享数据


线程的通信

由于线程的调度与执行存在随机性&争抢性,因此需要一些机制来保证线程的协作运行。
如果现在有存钱、取钱两个线程,要求不断重复存钱取钱操作,存钱后立刻取钱,
不允许有两次连续的存钱,也不允许有两次连续的取钱,那我们就需要这两个线程之间有通信机制。

wait、notify、notifyAll

wait()、notify()、notifyAll()是Object类的三个方法,任意一个对象都可以调用这三个方法,
但是必须在synchronized范围内并已经获取到synchronize锁并需要使用监视器的对象来调用。

方法名 描述
wait() 使当前线程进入等待状态并会释放synchronized锁(也可以设置超时时间,超时后自动恢复),使当前线程进入等待池&等待队列,直到其他线程调用当前监视器对象的notify()或notifyAll()来唤醒。
notify() 唤醒在当前对象监视器上等待的单个线程(唤醒等待队列&等待池中的一个线程),如果当前有多个线程处于等待状态,则会随机唤醒其中一个线程。
notifyAll() 唤醒在当前对象监视器上等待的所有线程(唤醒等待队列&等待池中的全部线程)。
/**
 * 如果现在有存钱、取钱两个线程,要求不断重复存钱取钱操作,存钱后立刻取钱,
 * 不允许有两次连续的存钱,也不允许有两次连续的取钱,那我们就需要这两个线程之间有通信机制。
 *
 */
public class NotifyDemo1 {
	public static void main(String[] args) {
		Item item = new Item();
		for (int i = 0; i < 10; i++) {
			new ThreadGet(item,"get"+i).start();
			new ThreadSave(item,"save"+i).start();
		}
	}
}

/**
 * 被两个线程共享的对象所属的类
 */
class Item {
	int count = 0 ;
	String flag = "save";
}

/**
 * 存钱线程
 *
 */
class ThreadSave extends Thread {
	Item item;

	public ThreadSave(Item item,String name) {
		super(name);
		this.item = item;
	}

	public void run() {
		try {
			synchronized (item) {
				if ("get".equals(item.flag)) {
					item.wait();
				}

				if ("save".equals(item.flag)) {
					item.count++;
					System.out.println(Thread.currentThread().getName() + " 存钱后,金额=" + item.count);
					item.flag = "get";
					item.notifyAll();
				}
			}
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
}
/**
 *	取钱线程
 */
class ThreadGet extends Thread {
	Item item;

	public ThreadGet(Item item,String name) {
		super(name);
		this.item = item;
	}

	public void run() {
		try {
			synchronized (item) {
				if ("save".equals(item.flag)) {
					item.wait();
//					System.out.println("-------------------"+
//							Thread.currentThread().getName() + "取钱被唤醒了,此刻的flag是"+item.flag);
					//取钱线程执行后调用item.notifyAll(),notifyAll()会唤醒所有处于item对象等待队列中的所有线程,即所有处于等待状态的存钱线程与取钱线程。
					//此刻,如果侥幸让一个取钱线程抢到了锁并执行,就会从item.wait()下面开始执行,在euqals判断时,因为flag=save而导致没有进入到if ("get".equals(item.flag)) 里,
					//因此这个线程执行完毕后,没有执行任何操作,那这个线程就相当于浪费掉了,save线程也是同理。
					//这就是所谓的“丢线程”
				}

				if ("get".equals(item.flag)) {
					item.count--;
					System.out.println(Thread.currentThread().getName() + " 取钱后,金额=" + item.count);
					item.flag = "save";
					item.notifyAll();
				}
			}
		} catch (Exception e) {
			e.printStackTrace();
		}
	}
}

注意:
当被唤醒的线程继续执行时,会继续执行object.wait()之后的代码,
而不会重新执行一次当前线程。
因此object.wait()的判断应该尽可能写在前面,如果object.wait()写在了代码的最后,
那即使线程被唤醒也是什么都做不了,因为刚被唤醒就执行结束了。

Condition

如果使用Lock锁而非Synchronized锁,就不存在监视器的概念了,
也不能再使用wait()、notify()、notifyAll()来进行线程通信。
取而代之的是使用Condition类来保证线程通信机制,
使用Condition可以让已得到Lock对象却无法继续执行的线程释放Lock对象,
也可以唤醒其他处于等待中的线程。

await(),signal(),await()是Condition类的方法,需要使用Condition对象来调用。

await():类似于wait(),导致当前线程进入等待状态,
直到其他线程调用Condition对象的signal()或signalAll()来唤醒该线程。

signal():类似于notify(),随机唤醒在当前Lock对象上等待的单个线程。

signalAll():类似于notifyAll(),唤醒在当前Lock对象上等待的全部线程。

public class ConditionDemo1 {
	public static void main(String[] args) {
		Bank b = new Bank();
		for (int i = 0; i < 10; i++) {
			new Thread(new SaveThread(b)).start();
			new Thread(new GetThread(b)).start();
		}
	}
}

class Bank {
	Integer count = 10;
	String flag = "save";

	final Lock lock = new ReentrantLock();
	final Condition condition = lock.newCondition();

	public void save() {
		try {
			lock.lock();
			if ("get".equals(flag)) {
				condition.await();
			} else {
				count++;
				System.out.println(Thread.currentThread().getName() + " 存钱后,金额=" + count);
				flag = "get";
				condition.signalAll();
			}
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			lock.unlock();
		}
	}

	public void get() {
		try {
			lock.lock();
			if ("save".equals(flag)) {
				condition.await();
			} else {
				count--;
				System.out.println(Thread.currentThread().getName() + " 取钱后,金额=" + count);
				flag = "save";
				condition.signalAll();
			}
		} catch (Exception e) {
			e.printStackTrace();
		} finally {
			lock.unlock();
		}
	}
}

class SaveThread implements Runnable {
	Bank bank;

	public SaveThread(Bank bank) {
		this.bank = bank;
	}

	public void run() {
		bank.save();
	}
}

class GetThread implements Runnable {
	Bank bank;

	public GetThread(Bank bank) {
		this.bank = bank;
	}

	public void run() {
		bank.get();
	}
}

BlockingQueue

JDK1.5提供了BlockingQueue阻塞队列接口,当生产者线程试图向BlockingQueue中放入元素时,
如果BlockingQueue已满,则生产者线程被阻塞,当消费者线程试图从BlockingQueue取出元素时,
如果BlockingQueue已空,则消费者线程被阻塞。
两个线程交替向BlockingQueue中放入&取出元素,可以控制线程通信。

BlockingQueue接口有5个实现类:

阻塞队列名称 描述
ArrayBlockingQueue 基于数组实现。
LinkedBlockingQueue 基于链表实现。
PriorityBlockingQueue 与PriorityQueue类似,该队列调用remove()、poll()、take()等方法取出元素时,并不是取出队列中存在时间最长的元素,而是队列中最小的元素,判断元素的大小根据元素本身大小来自然排序(实现Comparable接口),也可以使用Comparator进行定制排序。
SynchronousQueue 同步队列,对该队列的存取操作必须交替进行。
DelayQueue 底层基于PriorityBlockingQueue实现,要求元素必须实现Delay接口,该接口有一个getDelay()方法,DelayQueue根据元素的getDelay()返回值进行排序。
public class BlockingQueueDemo {
	public static void main(String[] args) throws Exception {
		BlockingQueue<String> bq = new ArrayBlockingQueue<>(2);
		new Producer(bq).start();
		new Producer(bq).start();
		new Producer(bq).start();
		new Consumer(bq).start();
		//生产者必须等待消费者消费后才能继续执行
	}
}
/**
 * 生产者
 */
class Producer extends Thread{
	BlockingQueue<String> bq ; 
	public Producer(BlockingQueue<String> bq) {
		this.bq = bq;
	}
	public void run() {
		for(int i = 0 ; i < 10 ; i++) {
			try {
				bq.put("a");
			} catch (InterruptedException e) {
				e.printStackTrace();
			}finally {
				System.out.println(this.getName()+" 生产完毕"+bq);
			}
		}
	}
}
/**
 * 消费者
 */
class Consumer extends Thread{
	BlockingQueue<String> bq ; 
	public Consumer(BlockingQueue<String> bq) {
		this.bq = bq;
	}
	public void run() {
		for(int i = 0 ; i < 10 ; i++) {
			try {
				String take = bq.take();
				System.out.println(this.getName()+" 消费完毕 "+take+" "+bq);
			} catch (InterruptedException e) {
				e.printStackTrace();
			} 
		}
	}
}


总结

今天我们学到了一些线程的基本知识,但这些知识才只是刚刚开始,
多线程玩到后面会有很多很深的知识点,包括但不局限于:
线程池、java内存模型、锁机制(自旋锁,锁消除,锁粗化,偏向锁,轻量级锁等)、concurrent包、原子类等等。
并且多线程概念也是面试经常出现的,请大家务必要掌握。


作业

使用3个线程,要求三个线程顺序执行,不允许使用sleep()强制让线程有顺序。(京东面试题)
线程A输出1、2、3,
线程B输出4、5、6,
线程C输出7、8、9,
线程A输出10、11、12,
线程B输出13、14、15,
以此类推,一直输出到1000为止。

你可能感兴趣的:(#,Java教学系列)