一 概述
1.引子
要了解“线程”,就要先了解“进程”的概念,我们可以在Windows系统任务管理器中看到一个个“进程”。
这么多的进程,看上去是同时执行的,但其实只是CPU在做快速的切换。
而一个进程,包含至少一个或者包含多个线程。
2.进程与线程的定义
(1) 进程:一个执行中的程序,是系统进行资源分配和调度的独立单元。
(每一个进程执行都有一个执行顺序,该顺序就是执行路径)
(2) 线程:进程中的一个独立的(执行顺序)控制单元。
(3) 关于Java.exe :Java JVM 启动的时候会有一个进程 Java.exe
该进程中至少有一个(当然就是多个)线程负责 Java 程序的执行。
而且这个线程运行的代码存在于main方法中。
该线程称为主线程。
例如:“迅雷”这个进程,它除了把下载分为多个下载的任务,还把任务分为多个部分,
同时进行发出请求,(将下载起点分成了N个地方),这就是多线程的下载了。
补充:
线程是进程中的内容,每个应用程序里面都有线程,它是程序的控制单元或者执行路径。
进程在内存中开辟了空间,而线程才是真正执行程序的单元。
其实更细节说明虚拟机JVM,JVM启动不止主线程一个线程,还有负责垃圾回收机制的线程。
javac 命令执行的之后可以在Windows的任务管理器看到编译进程javac.exe,java命令同理可以看到虚拟机执行的进程java.exe.
JVM启动的时候,会有一个进程java.exe,该进程中至少有一个线程在负责java程序的执行,
而且这个线程运行的代码存在于main方法中,该线程称之为主线程。
多线程存在的意义:可以让程序产生“同时执行”的效果,多段代码同时执行,提高效率。
二 了解如何创建线程
创建新执行线程有两种方法:
- 继承Thread类,该子类应重写Thread 类的 run 方法。
- 创建线程的另一种方法是声明实现 Runnable 接口的类。
1.继承Thread类
步骤:定义一个继承Thread类的类,重写Thread类中的run方法,调用线程的start方法。
start方法有两个作用:启动线程,调用run方法。
(注意!!!不调用start方法,这个线程是不会执行的,run里面的方法不会有效)
public class Test {
public static void main(String[] args) {
Demo demo = new Demo();
demo.start();
for (int x = 0; x <= 60; x++) {
System.out.println("demo --" + x);
}
}
}
class Demo extends Thread {
public void run() {
for (int x = 0; x <= 60; x++) {
System.out.println("run --" + x);
}
}
}
上面的程序:输出是
run --59
run --60
demo --10
demo --11
……
两者交替输出,各自到达60。
观察上面的程序,可发现运行结果每一次都不同,
是因为多个线程都在获取CPU的执行权。CPU执行到谁,谁就运行。
明确一点,在某一个时刻,只能有一个程序在运行。(多核除外)
CPU在做着快速的切换,以达到看上去同时运行的效果。
我们可以形象地把多线程运行行为理解为在互相抢夺CPU的执行权。
这就是多线程的一个特性:随机性。谁抢到谁执行,至于执行多长时间,CPU说了算。
题:解释为什么要重写run方法?
答:首先,Thread类用于描述线程。该类就定义了一个方法,用于存储线程要运行的代码,也就是run方法。
Thread类中的run方法,是用于存储线程要运行的代码。也就是说,要让你的程序多线程执行,就需要重写run方法。
所以就要重写run方法,将自定义代码存储在run方法中,让你想要执行的功能执行。
题:请说一下run()方法和start()方法的区别?
答:如果仅仅调用run()方法,则结果就是程序还是一个单线程程序;只有调用start()方法,程序才能启动多线程。
run()方法就像个容器,仅仅是封装多线程要运行的代码,并不能启动多线程;
Java程序不会创建线程,Java程序通过调用start()方法,
然后start()方法调用OS的底层代码,去启动多线程。
Demo d = new Dmo(); //创建好一个线程
//demo.start(); //开启线程并执行该线程的run方法
demo.run(); //这样会按顺序,执行完run -- 输出,再执行demo --输出。
//仅仅是对象调用方法,而线程创建了,并没有运行。run仅仅是封装了线程所要执行的代码而已。
2.实现Runnable接口
步骤:
(1)定义类实现Runnable接口
(2)覆盖Runnable接口中的run方法。将线程要运行的代码存放在该run方法中。
(3)通过Thread类建立线程对象。
(4)将Runnable接口的子类对象作为实际参数传递给Thread类的构造方法。
(5)调用Thread类的start方法开启线程并调用Runnable接口子类的run方法。
示例代码:
public class Test {
public static void main(String[] args) {
RunnableTest runnableTest = new RunnableTest();
Thread thread = new Thread(runnableTest);
thread.start();
/* 简化到一步到为,new Thread(new Runnable()).start(); */
}
}
class RunnableTest implements Runnable {
public void run() {
System.out.println("线程运行代码");
}
}
不常用的简化模式:
public class Test {
public static void main(String[] args) {
new Thread(new Runnable() {
public void run() {
System.out.println("线程运行代码");
}
}).start();
}
}
题:为什么要将Runnable接口的子类对象传递给Thread的构造方法?
答:因为自定义的run方法所属的对象是Runnable接口的子类对象。
由此看见,我们必须要让线程去执行指定对象的run方法,就必须明确该run方法所属的对象。
3.对比两种创建线程的区别
继承Thread类的方式:将线程运行代码放在Thread子类的run方法;
实现Runnable接口的方式:将线程代码存放在接口的子类的run方法中;
对比可知,实现接口的方式可避免单继承的局限性,更能体现面向对象的思想,
在创建线程时,也建议优先使用实现Runnable接口的方式。
三 线程运行的状态
线程的状态有:新建、运行、冻结、消亡、临时阻塞。
其实应该说是线程的“生命周期”,不同教程有不同的线程状态转换图,
在此以毕老师的视频教程图例为标准。它们之间的状态转换如下:
各种状态的简单解释:
新建:Thread t = new Thread();就创建了一个线程
运行:t.start();
冻结:t.sleep(500); t.wait(); (sleep有时间限制,而wait没有)
消亡:t.stop();
临时状态(阻塞):线程被start后,有不运行,因为CPU还要先运行完其它进程,该线程正在等CPU的执行权。
四 获取线程对象及名称
每个线程都有自己默认的名称,一般是"Thread-编号",编号从0开始。
而主线程的名称,则是“main”。
Thread类 封装了获取线程名称的方法:
- static Thread currentThread():获取当前线程对象。
- getName():获取线程名称。
- 设置线程名称:通过setName()方法或者构造方法设置。
- 注意:主线程不能设置名称,它的线程名是默认的(main)。
class ThreadTest extends Thread {
public void run() {
while (true) {
System.out.println(Thread.currentThread().getName() + "...run");
}
}
}
public class Test {
public static void main(String[] args) {
ThreadTest t1 = new ThreadTest();
ThreadTest t2 = new ThreadTest();
ThreadTest t3 = new ThreadTest();
t3.setName("ThreadTest");
t1.start(); //Thread-0...run
t2.start(); //Thread-1...run
t3.start(); //ThreadTest...run
while (true) {
System.out.println(Thread.currentThread().getName() + "...run"); //main...run
}
}
}
上面的程序,就是 注释里面的语句一直交替输出。
五 售票的例子
需求:简单的卖票程序,多个窗口同时卖票。
// 多个窗口同时卖100张票
public class Demo {
public static void main(String[] args) {
Ticket t = new Ticket();
new Thread(t, "窗口1").start();
new Thread(t, "窗口2").start();
new Thread(t, "窗口3").start();
new Thread(t, "窗口4").start();
}
}
class Ticket implements Runnable {
public static int tickets = 100;
public void run() {
while (tickets > 0) {
System.out.println(Thread.currentThread().getName() + " 卖出第 "
+ tickets-- + " 张票.");
}
}
}
六 多线程存在的安全隐患问题
1.模拟案例
像上面那样多线程卖票,会不会出现问题呢?
下面通过Thread的sleep()方法刻意去模仿这种情况的出现
class Ticket extends Thread {
private static int tick = 100;
public void run() {
while (tick > 0) {
if(tick==20){ //注意这里
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(Thread.currentThread().getName() + " ...sale:"
+ tick--);
}
}
}
public class TicketDemo {
public static void main(String[] args) {
new Ticket().start();
new Ticket().start();
new Ticket().start();
new Ticket().start();
}
}
最后的输出是:
……
Thread-1 ...sale:1
Thread-0 ...sale:0
Thread-2 ...sale:-1
想象一下这个极端情况,如果四个线程只卖1张票,
线程0在判断票数>0后,暂停了一下,
线程1进入判断后卖票了,票数减去1了,变成0张票,
当线程0暂停完毕,再卖票,得出的结果就会是-1。
所以,多线程就出现了安全问题。
不相关的知识点:接口的方法不能抛异常,只能try……catch。
2.问题原因(重点)
当多条语句在操作同一个线程共享数据时,
一个线程对多条语句只执行了一部分(,CPU就切换到其他线程去了),还没执行完,
另一个线程就参与进来执行,导致共享数据的错误。
3.解决方法
对多条操作共享数据的语句,只能让每一个线程都执行完。在执行过程中,其他线程不能参与。
Java对多线程的安全问题提供了专业的解决方式,
就是同步代码块:
synchronized(对象){
需要被同步的代码
}
操作共享数据的代码,就需要同步。
一个值得思考的问题:
用继承Thread类的方式,去写卖票程序,Ticket票数必须加上static。
而用实现接口Runnable的方式,Ticket票数类,则不需要加static。
否则,程序将不满足要求,为什么呢?
首先,用继承Thread类的方式,Ticket已经是Therad的子类了,
每次new Ticket()都会产生新的100张票,这显然不符合4个售票窗口卖100张票的要求。
再次,用实现Runnable接口的方式,它先要有一个实现Runnable接口的类Tick,
这个类定义好了线程所要执行的run()方法,再传入Thread类的构造方法中,
此时不会影响Tick类,它还是只有100张票,新建的4个线程就会共同操作Tick类的100张票,
因此,满足题目的要求,不需要加上static。
七 线程同步1 - 引入概念
1.简单示例代码
以上面卖票程序为例,加入同步代码块。
class Ticket implements Runnable {
private int tick = 100;
Object obj = new Object();
public void run() {
while (true) {
synchronized (obj) { //注意这里
if (tick > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()
+ " sale: " + tick--);
}
}
}
}
}
class TicketDemo {
public static void main(String[] args) {
Ticket t = new Ticket();
Thread t1 = new Thread(t);
Thread t2 = new Thread(t);
Thread t3 = new Thread(t);
t1.start();
t2.start();
t3.start();
}
}
可以看到,上面这个示例代码,用obj作为同步代码块的“锁”,使线程在同步中执行,保证了运行结果的正确。
2.“同步”的原理 - 加锁!
synchronized的原理,相当于加锁,
好像运行代码块里面的代码前,会先有道门(类似于标志位的机制,0是关,1是开),
当线程0运行 synchronized代码块里面的代码时,
先判断代码块里的代码有没有被上锁,如果没有那它自己就给标志位加锁,
然后就运行代码,这样就算它sleep了并且其他线程拿到了CPU执行权,
也只能等线程0运行完,释放锁后其他线程才能继续运行。
这样就避免了不同步导致的安全问题。
生活中的例子:火车里面每节车厢那唯一 一个厕所,要上厕所的人就是线程。
3.“同步”的前提
- 必须要有两个或者两个以上的线程。
- 必须是多个线程使用同一个锁。
必须保证同步中中只能有一个线程在运行。
这里注意第(2)点,加的“锁”可以是随便一个new Object(),
可以是 "随便".getClass() ,可以是 你这个文件名.class 都行,
只要你保证这么多个线程,它们用的锁,是同一个对象,就行了!
4.“同步”的利弊
好处:解决了多线程的安全问题。
弊端:多个线程都需要判断锁,较为耗费资源。
上锁后,明显感觉程序运行的时间慢了。越安全越耗资源,你加越多锁,越慢。
八 线程同步2 - 同步方法
1.银行金库问题
需求:银行有一个金库,有两个储户分别存300元,每次存100,存3次。
目的:该程序是否有安全问题,如果有,如何解决?
class Bank {
private int sum;
// private Object obj = new Object();
public synchronized void add(int num)// 同步方法
{
// synchronized(obj)
// {
sum = sum + num;
// -->
try {
Thread.sleep(10);
} catch (InterruptedException e) {
}
System.out.println("sum=" + sum);
// }
}
}
class Cus implements Runnable {
private Bank b = new Bank();
public void run() {
for (int x = 0; x < 3; x++) {
b.add(100);
}
}
}
class BankDemo {
public static void main(String[] args) {
Cus c = new Cus();
Thread t1 = new Thread(c);
Thread t2 = new Thread(c);
t1.start();
t2.start();
}
}
2.解题思路
如何找到问题?
- 明确哪些代码是多线程运行代码。 → run方法和add方法
- 明确共享数据。 → b和sum是共享数据
- 明确多线程运行代码哪些语句是操作共享数据的。 → sum=sum+n
所以,经过分析,sum=sum+n;那三句应该被同步。
3.同步方法
题:同步代码块是用来封装代码的,方法也是用来封装代码的,那它两有什么不一样呢?
答:同步代码块封装代码具备了同步性。
题:那如果让方法具备同步性呢?
答:可以把synchronized作为关键字修饰方法,让方法具备同步性。
因此引入了同步方法的概念。
public synchronized void add(int n) {
sum = sum + n;
System.out.println("sum= " + sum);
}
九 线程同步3 - 同步方法的锁
1.同步方法的锁
同步方法用的锁是this!!!
验证:使用两个线程来卖票。一个在同步代码块,一个在同步方法,
都在执行买票动作。如果没同步,则会出现错误的票。
线程t1一开启就运行同步代码块,线程t2一开启就运行同步函数。
class Ticket implements Runnable {
private static int Ticket = 500;
public static boolean flag = true;
public void run() {
if (flag) {
while (true) {
synchronized (this) {
if (Ticket > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()
+ " code--- " + Ticket--);
}
}
}
} else {
while (true)
show();
}
}
public synchronized void show() {
if (Ticket > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " show--- "
+ Ticket--);
}
}
}
public class Prove {
public static void main(String[] args) throws InterruptedException {
Ticket Ticket = new Ticket();
Thread t1 = new Thread(Ticket, "买家1");
Thread t2 = new Thread(Ticket, "买家2");
t1.start();
Thread.sleep(20);
Ticket.flag = false;
t2.start();
}
}
方法需要被对象调用,那么函数都有一个所属对象引用,就是this。
所以,同步方法使用的锁是 this!
2.静态同步方法的锁
如果同步函数被静态static修饰后,使用的锁是什么呢?
通过验证,将上面程序的同步函数加静态后(注意票数也要加static修饰了),
执行,发现不再是this。
因为静态方法也不可以定义this。
内存中有一个对象,字节码文件对象,Ticket.class 。
静态进内存时,内存中没有本类对象,但是一定有该类对应的字节码文件对象。
类名.class,该对象类型是Class类型。
静态的同步方法,使用的锁是该方法所在类的字节码文件对象。
类名.class,该对象在内存中是唯一的。
十 死锁
1.死锁的概念
死锁:你有一个锁,我有一个锁,你要锁定我的锁才能修改,我也要锁定你的锁才能修改。
通常死锁出现的情况,就是 同步中嵌套同步。
我们应该尽量避免死锁。
百度
所谓死锁:是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,
若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,
这些永远在互相等待的进程称为死锁进程。由于资源占用是互斥的,当某个进程提出申请资源后,
使得有关进程在无外力协助下,永远分配不到必需的资源而无法继续运行,
这就产生了一种特殊现象:死锁。
2.死锁的示例代码
class MyTest extends Thread {
private boolean flag;
public MyTest(boolean flag) {
super();
this.flag = flag;
}
public void run() {
if (flag) {
synchronized (Lock.LockA) {
System.out.println("if locka");
synchronized (Lock.LockB) {
System.out.println("if lockb");
}
}
} else {
synchronized (Lock.LockB) {
System.out.println("else lockb");
synchronized (Lock.LockA) {
System.out.println("else locka");
}
}
}
}
}
class Lock {
public static Object LockA = new Object();
public static Object LockB = new Object();
}
public class DeadLockDemo {
public static void main(String[] args) {
new MyTest(true).start();
new MyTest(false).start();
}
}
关键是要理解,“锁”作为线程在同步情况下所先要取得的资源,
只能在一个时刻被某一个线程锁定,一锁上就要等代码块执行完,
释放锁后,才能继续执行。
十一 单例设计模式
单例设计模式有两种方式,分别是饿汉式和懒汉式。
饿汉式:一上来就初始化了。
懒汉式:用到的时候才初始化。
1.饿汉式
//饿汉式
class Single {
private final static Single s = new Single();
private Single() {
}
public static Single getInstance() {
return s;
}
}
2.懒汉式
// 懒汉式
class AnotherSingle {
private static AnotherSingle as = null;
private AnotherSingle() {
}
public static AnotherSingle getInstance() {
if (as == null) {
synchronized (AnotherSingle.class) {
if (as == null)
as = new AnotherSingle();
}
}
return as;
}
}
public class Single {
public static void main(String[] args) {
AnotherSingle.getInstance();
}
}
懒汉式多线程访问下的安全隐患:
A线程判断完类实例为null后挂起了,这时候B线程获取到CPU资源判断进来,也挂起了,
然后A线程醒了new实例后B线程又醒了new实例,就不符合单例设计模式的要求了。
怎么改?加同步,并且用双重判断提高效率。
3.两种方式的对比
饿汉式和懒汉式的区别就在于 延时加载。
懒汉式有什么弊端?懒汉式在多线程运行下,可能会出现线程安全隐患。
该如何解决?加同步,并双重否定提高效率。