进程与线程的区别
进程
进程是正在运行的程序,是系统进行资源分配和调用的独立单位,没一个进程都有它自己的内存空间和系统资源。
线程
线程是进程中的单个顺序控制流,是一条执行路径。单线程程序和多线程程序的区分,就在于进程中是否有多条执行路径。如果你还是不知道执行路径是什么的话,那就看看这些例子:
-
你打开了一个【记事本】程序,然后打开它的【页面设置】界面,这时候你再点击输入框,先输入点东西,你发现一直在提醒你先把【页面设置】关闭再尝试输入。这就是只有一条执行路径的应用程序。
-
扫雷游戏,当你点击了一下其中的单元格,计时器就开始计时,这就是两个不同的线程所控制的。
快速开始
多线程的创建步骤
- 继承Thread类
- 重写run方法,run方法为线程体
- 调用线程的start()方法
public class MyThread01 extends Thread{
@Override
public void run() {
// 这里的值尽量设置大一点,否则执行太快,很难看到线程交错执行的效果
for (int i = 0; i < 100; i++) {
System.out.println(getName()+ " " + i);
}
}
public static void main(String[] args) {
MyThread01 mt = new MyThread01();
MyThread01 mt2 = new MyThread01();
mt.start();
mt2.start();
}
}
执行结果:
我们可以看到两个线程同时执行的效果,为了方便查看,我加入线程的名称再进行运行一次
我们可以看到两个线程在交错执行,没有出现等待谁执行完成后再执行的情况。这就是多线程最直观的案例。
修改线程的名称
上面打印出来线程的名称,其实是使用了线程的getName()
方法,通过这个方法可以返回线程的名称。在没有设置线程的名称时,线程会默认以Thread-
作为前缀。这部分可通过源码进行解读,我们在新建线程类的时候都是使用了线程的无参构造方法,所以我们查看线程的无参构造方法:
public Thread() {
this.init((ThreadGroup)null, (Runnable)null, "Thread-" + nextThreadNum(), 0L);
}
在这里我们可以直接看到了Thread-
的字样,我们就可以猜测这部分的代码实现了。name
作为Thread类中一个全局变量,在使用无参构造方法时,会调用init()
方法进行初始化赋值。我们先进入init()
方法一探究竟:我们发现init()
方法再调用了自身的重载方法,最终出现了关键的this.name
设置name属性。
到了这里,我们很好奇那个nextThreadNum()
做了些什么,他是怎么实现排号的呢?它先是定义了一个全局的静态线程计数变量,再将nextThreadNum()
方法实现同步锁机制,以防出现在多线程的情况获取了相同的计数值。
private static int threadInitNumber;
private static synchronized int nextThreadNum() {
return threadInitNumber++;
}
知道了原始的线程名称的由来了,那我们再探究一下怎么去实现修改线程的名称。有getName()
方法那自然少不了setName()
方法,
public final synchronized void setName(String var1) {
this.checkAccess();
if (var1 == null) {
throw new NullPointerException("name cannot be null");
} else {
this.name = var1;
if (this.threadStatus != 0) {
this.setNativeName(var1);
}
}
}
我们先不用管前面的方法做了什么判断,看到赋值是通过this.name = var1
就明白了。
那我们接下来用上面的例子来实现改名操作
public static void main(String[] args) {
MyThread01 mt = new MyThread01();
MyThread01 mt2 = new MyThread01();
mt.setName("飞机");
System.out.println(mt.getName());
}
那我们可能想通过构造方法来实现名称设置,我们再来查看一下源码:源码中正好有这么一个构造方法,在这里我也将无参构造方法一并拷过来,这么一对比,就恍然大悟了,它就是将你传进来的值替代了"Thread-" + nextThreadNum()
这段代码。
public Thread() {
this.init((ThreadGroup)null, (Runnable)null, "Thread-" + nextThreadNum(), 0L);
}
public Thread(String var1) {
this.init((ThreadGroup)null, (Runnable)null, var1, 0L);
}
那我们接下来实践一下看看是否真的能够实现构造方法修改名称。
public class MyThread01 extends Thread{
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println(getName()+ " " + i);
}
}
// 添加了有参和无参两个构造方法
public MyThread01() {
}
public MyThread01(String s) {
super(s);
}
public static void main(String[] args) {
MyThread01 mt = new MyThread01();
MyThread01 mt2 = new MyThread01();
mt.setName("飞机");
System.out.println(mt.getName());
MyThread01 mt3 = new MyThread01("高铁");
System.out.println(mt3.getName());
// mt.start();
// mt2.start();
}
}
修改线程名称的方式就上述所说的通过setName()
和有参构造方法
两种形式。
main方法执行所在的线程
我们经过前面的探索可以知道了getName()
可以获取线程的名称,那我们的main方法是属于哪个线程中的呢?main方法不是线程中方法,没办法通过Thread对象.getName()
来直接获得线程的名称。
这时候就要借助于Thread类中的一个静态方法:currentThread()
,该方法返回一个线程。
public static native Thread currentThread();
这个方法被native
所修饰,属于C++编写的代码。我们没办法直接查看该方法的源码,在这里我们就简单演示一下用该方法来获取main方法所在的线程的名称。
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName());
}
最后输出的结果为 main 线程
修改线程的优先级
Java的线程执行机制属于抢占式调度模型,所以会出现执行的时间片不相同的情况。比如上述的例子,执行每个线程的执行时间不固定,可以执行很长,也可以执行很短。而这个抢占式是基于线程的优先级来进行划分的。
我们可以通过getPriority()
来获取线程的优先级
MyThread01 mt = new MyThread01();
MyThread01 mt2 = new MyThread01();
mt.setName("飞机");
System.out.println(mt.getName());
MyThread01 mt3 = new MyThread01("高铁");
System.out.println(mt3.getName());
System.out.println("mt3的线程优先级:" + mt3.getPriority());
运行结果为5,这就是线程默认的优先级。我们回到源码中去探究一下,这线程的优先级的设值范围是多少。
public static final int MIN_PRIORITY = 1;
public static final int NORM_PRIORITY = 5;
public static final int MAX_PRIORITY = 10;
通过Thread类中定义的常量我们就可以知道,线程中的优先级范围是[1, 10]。接下来我们来看看设置线程优先级会有什么限制
public final void setPriority(int var1) {
this.checkAccess();
if (var1 <= 10 && var1 >= 1) {
ThreadGroup var2;
if ((var2 = this.getThreadGroup()) != null) {
if (var1 > var2.getMaxPriority()) {
var1 = var2.getMaxPriority();
}
this.setPriority0(this.priority = var1);
}
} else {
throw new IllegalArgumentException();
}
}
先抛开其他的复杂的逻辑不谈,我们大概知道,setPriority()
在取值为[1, 10]的范围是不会有什么问题的。接下来那我们就做个测试,看看线程优先级对线程执行顺序的影响。
mt.setPriority(10); // 飞机
mt2.setPriority(1); // 火车
mt3.setPriority(5); // 高铁
我预期出现的结果会是,先把【飞机】线程执行完成,再执行【高铁】线程,最后再执行【火车】线程。
但其实结果是这样:
飞机的还没执行完,就开始执行火车和高铁的线程了。这属实是意料之外的。
这让我重新再理解一次抢占式调度模型的概念:线程优先级高仅仅表示线程获取的CPU时间片的几率高,但是要在次数比较多,或者多次运行的时候才能看到一开始我预期的效果。
线程控制
sleep()
: 当前线程停留/暂停执行指定的毫秒数,这个方法也是被native
修饰的。
我们都知道前面的案例执行的顺序都是不固定的,谁也不知道哪个线程在什么时候执行。假如这个时候,我们突然有这么一个需求:我需要上面的3个线程间隔去执行,这个时候该怎么办?我们只能通过sleep()
方法,让每个线程执行完一次就暂停一下。
同样是上面的案例,在run方法中,设置输出一次就等待100毫秒,实现了3个线程的交替执行。
public class MyThread01 extends Thread{
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println(getName()+ " " + i);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public MyThread01() {
}
public MyThread01(String s) {
super(s);
}
public static void main(String[] args) {
System.out.println(Thread.currentThread().getPriority());
MyThread01 mt = new MyThread01();
MyThread01 mt2 = new MyThread01();
mt.setName("飞机");
mt2.setName("火车");
MyThread01 mt3 = new MyThread01("高铁");
mt.start();
mt2.start();
mt3.start();
}
}
join()
: 等待该线程死亡再执行下面的线程。
还记得我们前面在线程优先级的时候原本的预期是,飞机执行完后,再执行高铁,最后到火车。但是我们发现调整线程的优先级并不能绝对保证线程是执行顺序。这个时候,我们可以试试使用join()
方法。我们在【飞机】与【高铁】之间添加了join()
方法,在【高铁】与【火车】添加了join()
方法。
public class MyThread01 extends Thread{
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println(getName()+ " " + i);
}
}
public MyThread01() {
}
public MyThread01(String s) {
super(s);
}
public static void main(String[] args) throws InterruptedException {
System.out.println(Thread.currentThread().getPriority());
MyThread01 mt = new MyThread01();
MyThread01 mt2 = new MyThread01();
mt.setName("飞机");
mt2.setName("火车");
MyThread01 mt3 = new MyThread01("高铁");
mt.start();
mt.join();
mt3.start();
mt3.join();
mt2.start();
}
}
这个时候我们发现,一切都有序了
这就是join()
的作用,join()
也可以用于接收多线程中的返回值。
setDaemon()
:设置该线程为主线程的守护线程。主线程消亡的时候,守护线程也很快死亡。
这个时候,就得换一个案例了:刘备、张飞、关羽桃园3结义,不求同年同月同日生,但求同年同月同日死。那如果刘备挂了,张飞和关羽也得英勇赴死。
package ThreadTest1;
public class MyThread02 extends Thread {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println(getName() + " " + i);
}
}
public MyThread02() {
}
public MyThread02(String s) {
super(s);
}
public static void main(String[] args) {
MyThread02 guanyu = new MyThread02("关羽");
MyThread02 zhangfei = new MyThread02("张飞");
Thread.currentThread().setName("刘备");
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + " " + i);
}
guanyu.setDaemon(true);
zhangfei.setDaemon(true);
guanyu.start();
zhangfei.start();
}
}
运行结果:
根据代码,原本的【张飞】和【关羽】线程应该执行到99才结束的,但是由于设置为守护线程,在主线程【刘备】执行到9的时候,他两就不活了,直接挂了。
线程的生命周期
使用Runnable接口来创建线程
- 定义一个类MyRunnable实现Runnable接口
- 在MyRunnale类中重写run()方法
- 创建MyRunnable类的对象
- 创建Thread类的对象,把MyRunnable对象作为构造方法的参数
- 启动线程
// 1. 实现Runnable接口
public class MyRunnable implements Runnable{
// 2. 重写run方法
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + " " + i);
}
}
public static void main(String[] args) {
// 3. 创建MyRunnable对象
MyRunnable mr = new MyRunnable();
// 4.创建线程对象
Thread t1 = new Thread(mr);
Thread t2 = new Thread(mr);
// 5. 启动线程
t1.start();
t2.start();
}
}
认真看上面这段代码的同学,可能就已近发现了,为什么上面的getName()
不能直接调用,反而前面要用Thread.currentThread().getName()
的形式?那我们来看看Runnable接口中到底有些什么东西。
@FunctionalInterface
public interface Runnable {
void run();
}
@FunctionalInterface
注解的含义是【函数式接口】,用来标注该接口中有且仅有一个抽象方法。如果一个接口中包含不止一个抽象方法,那么不能使用@FunctionalInterface
,编译会报错。
Runnable接口中真的除了一个run方法什么都没,那我们怎么可能可以获取到当前线程的getName()
方法呢,只能通过Thread.currentThead()
来获取当前线程。所以涉及线程的其他操作,就只能新建Thread对象,将Runnable对象作为参数传入,再进行其他的关于线程的操作。
为什么要用Runnable接口
这个时候,有些同学就要说了,那Runnable这么麻烦,直接用Thread线程类不香吗?确实,使用Thread类来创建线程确实很省事。不过,要考虑Java单继承的特性,你继承了其他类的时候,又恰好需要用到多线程,这时候你总没办法继承Thread类了吧。除了这个,使用Runnable实现的线程,能够把线程和程序代码、数据有效分离,较好地体现了面向对象编程。
综上所述,相比于继承Thread类,实现Runnable接口的好处:
- 避免了Java单继承的局限性
- 适合多个相同程序的代码去处理同一个资源的情况,把线程和程序的代码、数据有效分离,较好地体现了面向对象编程。
线程安全/线程同步
线程同步这个问题,是多线程必须解决和搞明白的,也是面试的重点。我们都知道多线程是异步执行的,而且我们都不知道执行顺序。那可能有同学就不太明白,为什么需要用到线程同步呢?接下来我们来实战案例讲解。我建议认真看一遍下面的代码,有助于你理解线程同步的概念。
案例:
我们要实现一个卖票的流程,一共有100张票,当卖完的时候就不进行卖票操作了。
我们先编写好了一个线程代码
public class SellTicket implements Runnable{
int ticketNum = 100;
@Override
public void run() {
while (true) { // 这里的死循环模拟票售空时还有人来问的情况
if (ticketNum > 0) {
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票");
ticketNum--;
}
}
}
}
再编写一个测试类
public class Main {
public static void main(String[] args) {
SellTicket sellTicket = new SellTicket();
Thread t1 = new Thread(sellTicket, "窗口1");
t1.start();
}
}
目前模拟的效果是,只有一个窗口在售票,执行也是完全无差错的。
那这个时候,老板看了看手中的投诉信,再看了看外面排队买票的人的队伍,决定要多开两个窗口售票。那就是新增多两个线程
public class Main {
public static void main(String[] args) {
SellTicket sellTicket = new SellTicket();
Thread t1 = new Thread(sellTicket, "窗口1");
Thread t2 = new Thread(sellTicket, "窗口2");
Thread t3 = new Thread(sellTicket, "窗口3");
t1.start();
t2.start();
t3.start();
}
}
我们先粗略看一眼结果,确实是实现了3个窗口卖票了。效率大大提高
那这个程序也不符合现实逻辑,在卖票的时候,不应该办理手续什么的之类的吗?怎么可能一上来就卖出了。OK,那我们用个sleep()方法,模拟售票所花的时间,这总没问题了吧
public class SellTicket implements Runnable{
int ticketNum = 100;
@Override
public void run() {
while (true) {
if (ticketNum > 0) {
try {
Thread.sleep(100); // 添加了sleep()方法模拟卖票的办理手续时间
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票");
ticketNum--;
}
}
}
}
在执行之前,我们感觉这个程序很眼熟,很像上面的那个【交替执行】的程序,那这里会出现什么问题呢?执行一下
为什么会出现重复售出同一张票,而且出现了断码?
我们来结合代码分析一下,假设票号100的票重复售出了3次
public class SellTicket implements Runnable{
int ticketNum = 100;
@Override
public void run() {
while (true) {
if (ticketNum > 0) {
try {
// 1. 窗口1执行到这里休眠
// 2. 窗口2争夺CPU执行时间,执行到这里也休眠了
// 3. 窗口3争夺CPU执行时间,执行到这里也休眠了
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 假设3个线程按顺序唤醒
// 4. 窗口1已经将票号100的票售出
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票");
// 5. 这时候窗口2被唤醒了,票号还没自减,而窗口2再将票售出了
// 6. 很巧,窗口3也是在票号自减之前被唤醒了,又将售出
// 7. 在窗口1自减后进到下一层的时候又碰到了slee(),在窗口1被唤醒售票之前,窗口2和窗口3都自减了一遍,那等窗口1醒来的时候,就成了97票号了。中间断层了99和98
ticketNum--;
}
}
}
}
这售出的负数号的票也是和上面第7步一样的原因。
售票案例数据安全问题的解决
我们来分析一下,为什么会出现这种问题?这也是多线程出现数据安全问题的原因
- 是否是多线程环境
- 是否有共享数据
- 是否有多条语句操作共享数据
当上面的3个条件都满足时,程序肯定会出现数据安全问题。我们回来分析我们的程序:
- 3个售票窗口 -> 多线程环境
- 总共100张票 -> 共享数据
- 售票和票数自减 -> 多条语句操作共享数据
三个全中,所以我们的程序就会出现数据安全问题。
如何解决多线程的安全问题呢?
基本思想:让上面的三个条件不能同时满足。那我们的程序,就是要3个窗口售卖100张票,这两个是一定要留下来的,那只能去破坏【多条语句操作共享数据】这个条件。
怎么去实现呢?
把多条语句操作共享数据的代码锁起来,让任意时刻只能有一个线程访问,这就可以保证操作的数据安全。而这个【锁】的操作,Java中提供了同步代码块的方式来解决。
synchronized(任意对象) {
多条语句操作共享数据
}
这任意对象就是随便的一个对象都行,没什么特别用处,就是用于标记为同一个锁即可,所以你不能写成
synchronized(new Object())
,这样的话,每次访问都不是同一个锁,无法保证共享数据的同步执行。
我们用synchronized
同步代码块来改写程序
public class SellTicket implements Runnable{
int ticketNum = 100;
Object object = new Object(); // 声明了一个Object对象用于标记同步锁
@Override
public void run() {
while (true) {
synchronized (object) { // 将多条语句操作共享数据的代码包括起来
if (ticketNum > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票");
ticketNum--;
}
}
}
}
}
这时候一运行,就把数据安全问题解决了,注意窗口之间票号衔接就知道了运行是不是正确的了。
那我们再回到代码中,讲解一下同步代码块的执行顺序:假设当前票号为100,三个线程按顺序执行
public class SellTicket implements Runnable{
int ticketNum = 100;
Object object = new Object();
@Override
public void run() {
while (true) {
// 1.窗口1来到了同步代码块
// 3.窗口2来到了同步代码块,发现被锁住了,哪怕窗口1是锁门睡觉的,我也等
// 4.窗口3也一样,等锁释放
synchronized (object) {
// 8.锁释放,窗口2进来了……
// 9.重复类似窗口1的操作
if (ticketNum > 0) {
try {
// 2.窗口1睡着了
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 5.窗口1睡醒了,卖票
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票");
// 6.卖出了一张,票号减1
ticketNum--;
}
}
// 7.窗口1释放锁
}
}
}
那同步代码是不是就真的完全只有好处,没有弊端呢?
肯定不是的,我们的这个案例,使用同步代码块之后,执行的时间比异步执行的时候慢很多很多
- 好处:解决了多线程的数据安全问题
- 弊端:当线程很多的时候,每个线程都会去判断同步上的锁,非常耗费资源,无形中会降低程序的运行效率。
同步方法的使用
还是上面的售票案例,如果我们有这样的一个需求,当 x % 2 == 0 时,这张票是一等座,贵一点,否则就是二等座。
public class SellTicket implements Runnable{
int ticketNum = 100;
Object object = new Object();
@Override
public void run() {
while (true) {
if (ticketNum % 2 == 0) {
synchronized (object) {
if (ticketNum > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 一等座");
ticketNum--;
}
}
} else {
synchronized (object) {
if (ticketNum > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 二等座");
ticketNum--;
}
}
}
}
}
}
执行没什么问题
这个时候,你感觉这样重复的代码很多啊,维护不便,以后被人看到成千古罪人,得把【二等座】的逻辑抽成一个方法。于是把代码修改成这个样子
package ThreadDemo;
public class SellTicket implements Runnable{
int ticketNum = 100;
Object object = new Object();
@Override
public void run() {
while (true) {
if (ticketNum % 2 == 0) {
synchronized (object) {
if (ticketNum > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 一等座");
ticketNum--;
}
}
} else {
secondClassSeat();
}
}
}
public void secondClassSeat() {
synchronized (object) {
if (ticketNum > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 二等座");
ticketNum--;
}
}
}
}
运行测试也通过。
如果之前有了解过synchronized
关键字的同学这个时候就要说了,那把synchronized
写到方法上不就好了嘛,还管这么多。
**格式:**修饰符 synchronized 返回值类型 方法名(方法参数) {}
说干就干,于是将synchronized
提到了方法上
public synchronized void secondClassSeat() {
// synchronized (object) {
if (ticketNum > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 二等座");
ticketNum--;
}
// }
}
没任何报错,看来语法是正确的,来运行一下
这怎么回事啊,怎么就锁不住了?
我们在上面讲synchronized
的时候有提到过,线程的锁对象必须是同一个对象才能锁住共享资源,用同步代码块的时候,我们可以通过synchronized(同一对象)
的方式来指定同一个锁对象。那我们的方法上的synchronize
关键字又不能指定对象是吧,那同步方法的锁对象是什么呢?这里就不卖管子了,直接说:this 。就是他的对象本身。
那这个程序怎么改,就简单了,讲上面的同步代码块的对象改成了synchronized(this)
即可。
public void run() {
while (true) {
if (ticketNum % 2 == 0) {
synchronized (this) { // <------------- 改成this
if (ticketNum > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 一等座");
ticketNum--;
}
}
} else {
secondClassSeat();
}
}
}
运行结果,又回到了正常
这时候又想把刚才的【二等座】方法改成静态的了
格式:修饰符 static synchronized 返回值类型 方法名(方法参数) {}
package ThreadDemo;
public class SellTicket implements Runnable{
static int ticketNum = 100; // <---------- static
Object object = new Object();
@Override
public void run() {
while (true) {
if (ticketNum % 2 == 0) {
synchronized (this) {
if (ticketNum > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 一等座");
ticketNum--;
}
}
} else {
secondClassSeat();
}
}
}
public static synchronized void secondClassSeat() { // <---- static
// synchronized (object) {
if (ticketNum > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 二等座");
ticketNum--;
}
// }
}
}
再运行一遍,结果怎么又出问题了!
会不会还是由于锁对象不同出现的数据安全问题?确实是这样的,同步静态方法是类的方法,他的锁对象是类而不是对象。所以锁对象应该是:类名.class
package ThreadDemo;
public class SellTicket implements Runnable{
static int ticketNum = 100;
Object object = new Object();
@Override
public void run() {
while (true) {
if (ticketNum % 2 == 0) {
synchronized (SellTicket.class) { // <-------- 锁对象修改为SellTicket.class
if (ticketNum > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 一等座");
ticketNum--;
}
}
} else {
secondClassSeat();
}
}
}
public static synchronized void secondClassSeat() {
// synchronized (object) {
if (ticketNum > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 二等座");
ticketNum--;
}
// }
}
}
再次运行,就又回到正常了
总结同步方法的两个重要知识点
- 同步方法的锁对象是this
- 同步静态方法的锁对象是类名.class
线程安全的类
我们最常听说的线程安全的类就是StringBuffer、Vector、Hashtable这3个,先说说这3个类的作用
- StringBuffer:
- 线程安全,可变的字符序列
- 从JDK 5开始,被StringBuilder替代。通常应该使用StringBuilder类,因为他支持所有相同的操作,因为它不执行同步,所以性能更快
- Vector
- Vector类实现了可拓展的对象数组
- 从Java2平台v1.2开始,该类改进了List接口,使其成为Java Collections Framework的成员。与新的集合平台不同,Vector被同步。如果不需要线程安全的实现,建议使用ArrayList替代Vector
- Hashtable
- 该类实现了一个哈希表,它将键映射到值。任何非null对象都可以用作键或者值
- 从Java2平台v1.2开始,该类改进了Map接口,使其成为Java Collections Framework的成员。与新的集合平台不同,Hashtable被同步。如果不需要线程安全的实现,建议使用HashMap替代Hashtable
我们很好奇,线程安全的类,到底是怎么实现线程安全的?也是通过同步锁来做的吗?
下面这是一段StringBuffer中的代码片段,除了构造方法以外,几乎所有的涉及共享数据操作的方法都加上了同步锁。
而StringBuilder中的代码,一个同步锁都没有
Hashtable和Vector也是同样的操作,所以我们明白了,线程安全的类,就是将涉及共享数据的操作方法添加同步锁来实现的。
不过,在多线程的环境下,StringBuffer可能我们会常用,那个Vector和Hashtable,我们是真没见过在实际项目中排上用场的。我们一般使用线程同步的ArrayList,都是通过Collections这个工具类中的synchronizedList()
方法来获取一个同步的List集合。
List<Integer> integers = Collections.synchronizedList(new ArrayList<Integer>());
同样的可以有synchronizedMap()
、synchronizedSet()
。
我们又很好奇,这个synchronizedList()
到底做了什么能实现同步。
一路寻根,它最终的实现类在SynchronizedList中,截取一段代码片段,我们发现它并没有使用同步方法,而是使用了同步代码块的方式
通过这次学习,我们知道线程安全,都是通过同步方法、同步代码块来实现的,并没有其他神奇的操作。
补充一下:基于性能的考虑,线程安全的HashMap,我们不考虑通过synchronizedMap()
方法来返回,而是使用ConcurrentHashMap类,这个类实现同步的方式,是通过实现可重入锁,将锁的粒度继续细分,达到高效的性能。这个后面讲到锁的时候再细说。
Lock锁
虽然我们可以理解同步代码块和同步方法的锁对象问题,但是我们并没有直接看到在哪里加上了锁,在哪里释放了锁,为了更清晰的表达如何加锁和释放锁,JDK5以后提供了一个新的锁对象Lock。
Lock实现提供比使用synchronized方法和语句可以获得更广泛的锁定操作
Lock中提供了获得锁和释放锁的方法
- void lock(): 获取锁
- void unlock(): 释放锁
Lock是一个接口,不能直接被实例化,这里采用它的实现类ReentrantLock来实例化
ReentrantLock的构造方法
- ReentrantLock(): 创建一个ReentrantLock的实例
我们回到刚才的程序来使用锁保证数据安全
public class SellTicket implements Runnable{
int ticketNum = 100;
Lock lock = new ReentrantLock(); // <-------- 申明锁
@Override
public void run() {
while (true) {
try {
lock.lock(); // <------- 锁定
if (ticketNum > 0) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + ":" + "当前在售出第" + ticketNum + "张票 一等座");
ticketNum--;
}
} finally {
/* 注意:释放锁的操作一定要放到finally中!否则因为当前线程
* 运行错误,无法释放锁,导致其他线程无法获取资源,导致死锁
*/
lock.unlock(); // <----- 解锁
}
}
}
}
实现的效果和使用synchronized关键字也是一样的
生产者和消费者模型
在现实生产中,多线程更常用于协助工作的场景。生产者生产资源,放到暂存区中,消费者消耗资源。通过学习掌握这个常用的多线程模型,帮助我们进一步理解多线程。
为了体现生产和消费过程的等待和唤醒,Java提供了几个方法供我们使用,这几个方法在Object类中。注意这3个方法是Object类的,不是Thread类的!(我当年笔试的时候就忘了这个,答错了)
Object类中的等待和唤醒方法:
方法名 | 说明 |
---|---|
void wait() | 导致当前线程等待,直到另一个线程调用该对象的notify()或notifyAll()方法 |
void notify() | 唤醒正在等待对象监视器(监视器就是锁)的单个线程 |
void notifyAll() | 唤醒正在等待对象监视器的所有线程 |
Object.wait() 与 Thread.sleep() 的异同
- 两者最主要的区别在于:
sleep()
方法没有释放锁,而wait()
方法释放了锁 。- 两者都可以暂停线程的执行。
wait()
通常被用于线程间交互/通信,sleep()
通常被用于暂停执行。wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法。sleep()
方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)
超时后线程会自动苏醒。
notify() 与 notifyAll()
当你调用notify时,只有一个等待线程会被唤醒而且它不能保证哪个线程会被唤醒,这取决于线程调度器。
notifyAll() 会将所有等待当前锁的线程唤醒,共同竞争锁资源
接下来我们设计一个案例:你预定了30天的牛奶,送奶工人每天会将一瓶牛奶送到你门口的奶箱。当牛奶送达后,你可以去拿走牛奶去喝。
我们很容易得知,这里的送奶工人其实就是 生产者,奶箱就是 暂存区,而你就是 消费者。生产者在中可以调用暂存区的存入方法,消费者则是调用暂存区中的取出方法。根据上面所述的,这个程序就很容易被设计出来。
StagingArea 【暂存区】
public class StagingArea {
public void put(int num) {
System.out.println("存入第" + num + "天的牛奶");
}
public void get(int num) {
System.out.println("取出第" + num + "天的牛奶");
}
}
Producer 【生产者】
public class Producer implements Runnable{
StagingArea stagingArea;
public Producer(StagingArea stagingArea) {
this.stagingArea = stagingArea;
}
@Override
public void run() {
for (int i = 1; i <= 30; i++) {
stagingArea.put(i);
}
}
}
Consumer 【消费者】
public class Consumer implements Runnable{
private StagingArea stagingArea;
public Consumer(StagingArea stagingArea) {
this.stagingArea = stagingArea;
}
@Override
public void run() {
for (int i = 1; i <= 30; i++) {
stagingArea.get(i);
}
}
}
Main 【主函数】
public class Main {
public static void main(String[] args) {
StagingArea stagingArea = new StagingArea();
Producer producer = new Producer(stagingArea);
Consumer consumer = new Consumer(stagingArea);
Thread c = new Thread(consumer);
Thread p = new Thread(producer);
c.start();
p.start();
}
}
看看运行结果
由于多线程的无序性,出现了奇怪的现象,我这牛奶还没存入,怎么取出来的?这肯定不符合现实的逻辑,我们的消费者得等待生产者将牛奶放入暂存区,才可以取出来。那我们一步一步慢慢探索
我们先在StagingArea中的get方法中加入wait()
方法,让消费者等待生产者提供牛奶
public void get(int num) {
// 等待生产者提供牛奶
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("取出第" + num + "天的牛奶");
}
一运行,发生报错
IllegalMonitorStateException
官网给出的描述翻译过来是:抛出该异常表明某一线程已经试图等待对象的监视器,或者试图通知其他正在等待对象的监视器,然而本身没有指定的监视器的线程。再次提醒一遍,监视器就是锁,就是说你的线程没有锁,所以不能执行这个方法。
那简单好办,加个synchronized
关键字即可
public synchronized void get(int num) {
// 等待生产者提供牛奶
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("取出第" + num + "天的牛奶");
}
运行一下,是没任何报错,不过这线程怎么被阻塞掉了,就没运行过一次,程序也不会结束,一直在等
原因也很简单,那是你一直在【wait】,根本就没唤醒过,那我们来唤醒一下,为了方便起见,这里就直接调用了【notifyAll】了
public synchronized void put(int num) {
System.out.println("存入第" + num + "天的牛奶");
notifyAll();
}
我们会发现,这怎么都放了30天了,牛奶才拿3天的?因为取牛奶的线程会被阻塞,所以执行效率是一定会比放牛奶的慢,放牛奶的可能执行了N次唤醒,取牛奶的线程才执行完一次。下一次又阻塞了,又得等放牛奶的操作来唤醒取牛奶的操作。所以导致了最后牛奶都放完30天了,取牛奶还在慢吞吞。
因此,我们需要让放牛奶的操作等待取牛奶的操作。这简单,不就在放牛奶的操作里面加一个wait等一下取牛奶的操作,然后取牛奶的操作唤醒放牛奶的操作。so easy! 妈妈再也不用担心我的多线程了!
public class StagingArea {
public synchronized void put(int num) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("存入第" + num + "天的牛奶");
notifyAll();
}
public synchronized void get(int num) {
// 等待生产者提供牛奶
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("取出第" + num + "天的牛奶");
notifyAll();
}
}
运行看看
空白输出,但是程序没有停止。这是两个线程都阻塞住了,经典的环路等待,一开始暂存区没有牛奶,等待放牛奶,但是这个放牛奶的一开始就在等待取牛奶的去唤醒他。这怎么行!我们得让送牛奶的那个知道,一开始是没有牛奶的!我们得加个标注,有牛奶就取,没牛奶就等放。
public class StagingArea {
// 标注有无牛奶
private boolean state = false;
public synchronized void put(int num) {
// 如果有牛奶
if (state) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 没有牛奶
System.out.println("存入第" + num + "天的牛奶");
state = true;
notifyAll();
}
public synchronized void get(int num) {
// 等待生产者提供牛奶
if (!state) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("取出第" + num + "天的牛奶");
state = false;
notifyAll();
}
}
最终程序得以完美运行
总结
写了这么多,主要是带大家熟悉了多线程的创建方法,同步的使用,锁的使用,以及Object类关于线程的方法的使用。根据具体的使用场景和可能遇到的问题,详细地给大家介绍了多线程的使用场景和问题解决。希望大家通过这篇文章可以快速掌握多线程的基本使用,下一步进阶学习线程池的操作。