为了面试而战--多线程(上)
什么是多线程
多线程是Java面试中常考的,我看到很多人简历上包括公司职位要求都单独写出会多线程,所以就来学习学习,之前学过一点,也记不住多少了,借此机会好好学学。
多线程拆看来看,多和线程。什么是线程呢?线程是操作系统能够进行运算调用的最小单位。被包含在进程中,是进程中的实际运行单位。简单来说,线程就是应用软件互相独立并且可以同时运行的功能,如果这种功能很多,就成为多线程。
那多线程有什么用呢?之前我们学习的代码,都是从上往下依次执行,执行一句话的时候Cpu无法执行下面的话,Cpu只能等着,这样执行效率低,这种程序被称为单线程程序。多线程程序的特点就是能同时执行多个事情,可以在多个程序之间进行切换,把等待的时间利用起来,提高了执行效率。
最后总结一下,想三个问题:
- 什么是多线程:不用背概念,就知道多线程可以让程序同时做多件事情
- 多线程的作用:提高执行效率
- 多线程的应用场景:比如你想让多个事情同时进行就会用到多线程
再来学习两个概念,并发和并行。
并发是指在同一时刻,有多个指令在单个CPU上交替执行,也就是CPU在多个线程之间交替执行;并行是说在同一时刻,有多个指令在多个CPU上同时执行,比如两个CPU两个线程同时执行。
多线程的实现
学会了什么是线程,现在就来学学怎么写线程,多线程一共有三种实现方式:
- 继承Thread类的方式进行实现
- 实现Runnable接口的方式进行实现
- 利用Callable接口和Future接口方式实现
/* 多线程的第一种启动方式: 1.自己定义一个类继承Thread 2.重写run方法 3.创建子类的对象,并启动线程 */ public class MyThread extends Thread{ @Override public void run() { //书写线程要执行代码 for (int i = 0; i < 100; i++) { //先获取 System.out.println(getName() + "HelloWorld"); } } } public class ThreadDemo { public static void main(String[] args) { //一会儿执行线程1,一会儿执行线程2 MyThread t1 = new MyThread(); MyThread t2 = new MyThread(); //起名字,为了区分 t1.setName("线程1"); t2.setName("线程2"); t1.start();//启动线程,不可以直接调用run()方法,那就只是调用方法了 t2.start(); } }
/* 多线程的第一种启动方式: 1.自己定义一个类继承Runnable接口 2.重写run方法 3.创建自己类的对象 4.创建Thread类的对象,并开启线程 */ public class MyRun implements Runnable{ @Override public void run() { //书写线程要执行的代码 for (int i = 0; i < 100; i++) { //Thread.currentThread():获取到当前线程的对象,那个线程执行了这个方法,就把这个线程对象返回 /*Thread t = Thread.currentThread(); System.out.println(t.getName() + "HelloWorld!");*/ //不能直接调用getName()了,因为getName()是Thread类中的方法,之前子类调用父类方法完全没问题,现在不是子类了,就不能直接用了。 System.out.println(Thread.currentThread().getName() + "HelloWorld!"); } } } public class ThreadDemo { public static void main(String[] args) { //创建MyRun的对象 //表示多线程要执行的任务,理解为任务对象 MyRun mr = new MyRun(); //创建线程对象,把任务传递给线程,并且线程1,2的任务是一样的 Thread t1 = new Thread(mr); Thread t2 = new Thread(mr); //给线程设置名字 t1.setName("线程1"); t2.setName("线程2"); //开启线程 t1.start(); t2.start(); } }
/* 第三种是对前两种的补充,第一种的run()是没有返回值的,所以这个时候就不能获取多线程运行的结果,第二种也是如此,没有返回值。那我就想要线程运行后的结果,该怎么办呢? 第三种实现的特点:可以获取多线程运行的结果 1.创建一个类MyCallable实现Callable接口 2.重写call(是有返回值的,表示多线程运行的结果) 3.创建MyCallable的对象(表示多线程要执行的任务) 4.创建FutureTask对象(作用管理多线程运行结果) 5.创建Thread类对象,并启动,表示线程 */ //Callable接口是有泛型的,用于指定返回值的类型 public class MyCallable implements Callable<Integer> { @Override //Integer表示多线程运行的结果 public Integer call() throws Exception { //求1~100之间的和 int sum = 0; for (int i = 1; i <= 100; i++) { sum = sum + i; } return sum; } } public class ThreadDemo { public static void main(String[] args) throws ExecutionException, InterruptedException { //创建MyCallable的对象(表示多线程要执行的任务) MyCallable mc = new MyCallable(); //创建FutureTask的对象(作用管理多线程运行的结果),要把mc放进去 FutureTask<Integer> ft = new FutureTask<>(mc); //创建线程的对象,把多线程返回值结果方法Thread中 Thread t1 = new Thread(ft); //启动线程 t1.start(); //获取多线程运行的结果,FutureTask就是用来管理结果的,肯定从这里拿 Integer result = ft.get(); System.out.println(result); } }
最后总结一下这三种多线程的创建方式,前两种无法获取到多线程运行结果,第三种是可以的。前两种也是有区别的,继承Thread的这种方式代码简单,可以直接用Thread中的方法(getName()),缺点就是可扩展性差,无法再继承其他类。
多线程常用方法
下面我们来看看多线程中常用的成员方法。
/* String getName() 返回此线程的名称 void setName(String name) 设置此线程的名字(构造方法也可以设置) 细节:如果我们没给线程名字,默认也是有的,为Thread-X static Thread currentThread() 获取当前线程的独享 细节:当JVM虚拟机启动后,会自动的启动多条线程,其中有一条就叫main线程,他的作用就是调用main方法,并执行里面代码。在以前自己写的所有代码都运行在main线程中 static void sleep(long time) 让线程休眠指定的时间,单位为毫秒 细节: 1.哪条线程执行到这个方法,那么哪条线程就会在这里停留一段时间 2.方法的参数:就表示睡眠的时间,单位是ms 3.当时间到了之后,线程会自动醒来,继续执行下面的其他代码 */ public class MyThread extends Thread{ public MyThread() { } //Thread类可以通过构造方法给线程名称,类的继承中,构造方法不能继承,子类也想像这样通过构造方法起名字就得自己写构造然后用super关键字继承父类构造方法 public MyThread(String name) { super(name); } @Override public void run() { for (int i = 0; i < 100; i++) { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(getName() + "@" + i); } } } public class ThreadDemo { public static void main(String[] args) throws InterruptedException { //1.创建线程的对象 MyThread t1 = new MyThread("飞机"); MyThread t2 = new MyThread("坦克"); //2.开启线程 t1.start(); t2.start(); //哪条线程执行到这个方法,此时获取的就是哪条线程的对象 /* Thread t = Thread.currentThread(); String name = t.getName(); System.out.println(name);//main*/ /*System.out.println("11111111111"); Thread.sleep(5000); System.out.println("22222222222");*/ } }
以上是比较简单的成员方法,下面学点复杂的,和线程优先级相关的方法。那什么是线程优先级呢,说道优先级,咱们就要说一下线程的调度。在计算机中,线程的调度有两种,第一种叫抢占式调度。多个线程在抢夺CPU执行权,CPU在什么时候执行哪个线程是不确定的,执行多长时间也是不确定的,主打一手随机性。第二种就是非抢占式调度,所有线程轮流执行,你一次我一次,执行时间也差不多。Java中采用的是抢占式调度,强调随机性,而优先级就跟随机有关系,优先级越大,这条线程抢到优先级的概率就是最大的,优先级最小是1,最大是10,默认是5。
public class MyRunnable implements Runnable{ @Override public void run() { for (int i = 1; i <= 100; i++) { System.out.println(Thread.currentThread().getName() + "---" + i); } } } public class ThreadDemo { public static void main(String[] args){ /* setPriority(int newPriority) 设置线程的优先级 final int getPriority() 获取线程的优先级 */ //创建线程要执行的参数对象 MyRunnable mr = new MyRunnable(); //创建线程对象 Thread t1 = new Thread(mr,"飞机"); Thread t2 = new Thread(mr,"坦克"); //System.out.println(t1.getPriority);默认为5 t1.setPriority(1); t2.setPriority(10); t1.start(); t2.start(); } }
优先级只是概率,优先级高抢到CPU的概率大,并不是一定能抢到。
接着学习下一个方法,守护线程。
public class MyThread1 extends Thread{ @Override public void run() { for (int i = 1; i <= 10; i++) { System.out.println(getName() + "@" + i); } } } public class MyThread2 extends Thread{ @Override public void run() { for (int i = 1; i <= 100; i++) { System.out.println(getName() + "@" + i); } } } public class ThreadDemo { public static void main(String[] args) { /* final void setDaemon(boolean on) 设置为守护线程 细节: 当其他的非守护线程执行完毕之后,守护线程会陆续结束 通俗易懂: 当女神线程结束了,那么备胎也没有存在的必要了 */ MyThread1 t1 = new MyThread1(); MyThread2 t2 = new MyThread2(); t1.setName("女神"); t2.setName("备胎"); //把第二个线程设置为守护线程(备胎线程) t2.setDaemon(true); t1.start(); t2.start();//肯定是女神线程先结束,然后备胎线程慢慢结束 } }
举个例子,我们在用qq聊天框,一边聊天一边发文件,聊天是一个线程,发送文件也是一个线程,把聊天窗口关闭了,也就是线程1结束了,那线程2还有必要存在吗,肯定也结束,所以就可以把线程2设为守护线程。
下一个方法是礼让线程yield(),正常有两个线程要执行的时候,两个线程执行是不均匀的,并不是线程1线程2线程1这样的,那我要是想让结果尽可能均匀一点呢?就可以在run()中直接调用yield()方法。
public class MyThread extends Thread{ @Override public void run() {//"飞机" "坦克" for (int i = 1; i <= 100; i++) { System.out.println(getName() + "@" + i); //表示出让当前CPU的执行权,让结果尽可能均匀一点 有可能飞机刚出让有抢到了 Thread.yield(); } } } public class ThreadDemo { public static void main(String[] args) { /* public static void yield() 出让线程/礼让线程 */ MyThread t1 = new MyThread(); MyThread t2 = new MyThread(); t1.setName("飞机"); t2.setName("坦克"); t1.start(); t2.start(); } }
还有一个方法叫插队线程/插入线程,
public class MyThread extends Thread{ @Override public void run() { for (int i = 1; i <= 100; i++) { System.out.println(getName() + "@" + i); } } } public class ThreadDemo { public static void main(String[] args) throws InterruptedException { /* public final void join() 插入线程/插队线程 */ //土豆和main抢夺CPU资源,我想把土豆插入到main之前,等土豆全都执行完了main再执行 MyThread t = new MyThread(); t.setName("土豆"); t.start(); //表示把t这个线程,插入到当前线程之前。 //t:土豆 //当前线程: main线程 这段代码运行在main线程上 t.join(); //执行在main线程当中的 for (int i = 0; i < 10; i++) { System.out.println("main线程" + i); } } }
线程的生命周期
之前的方法已经学习完,现在来学习一下线程的生命周期,存在各种状态。
一个小问题,sleep方法会让线程睡眠,睡眠时间到了之后,立马就会执行下面的代码吗?答案是不会的,时间到了还处在就绪状态,要去抢。
线程安全
以上是多线程的基本知识,多线程可以帮助我们提升效率,但是有一个弊端,就是不安全。下面我们来学习线程安全问题
/* 这个代码执行结果就是,三个窗口是独立的,各卖各的,相当于卖了三百张。 */ public class MyThread extends Thread { int ticket = 0;//0 ~ 99 @Override public void run() { while (true) { if (ticket < 100) { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } ticket++; System.out.println(getName() + "正在卖第" + ticket + "张票!!!"); } else { break; } } } } public class ThreadDemo { public static void main(String[] args) { /* 需求: 某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟该电影院卖票 */ //创建线程对象 MyThread t1 = new MyThread(); MyThread t2 = new MyThread(); MyThread t3 = new MyThread(); //起名字 t1.setName("窗口1"); t2.setName("窗口2"); t3.setName("窗口3"); //开启线程 t1.start(); t2.start(); t3.start(); } }
/* 还是会有重复的,而且还会越界,超出100张 */ public class MyThread extends Thread { //表示这个类所有的对象,都共享ticket数据 static int ticket = 0;//0 ~ 99 @Override public void run() { while (true) { if (ticket < 100) { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } ticket++; System.out.println(getName() + "正在卖第" + ticket + "张票!!!"); } else { break; } } } }
卖票程序会产生问题,当多个线程操作同一个数据时就会出现问题,第一个问题就是出现重复数据,相同的票出现了多次,第二个问题是数据越界,出现了超出范围的票。到底是为什么呢?
while (true) { if (ticket < 100) { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } ticket++; System.out.println(getName() + "正在卖第" + ticket + "张票!!!"); } else { break; } }
我们就来看一下上面这块代码。假如有三个线程1、2、3,线程1抢到CPU之后立刻进入if中,然后进入睡眠,其他两个线程开始抢夺。现在线程2抢到了,也去睡眠了,CPU执行权就被线程3抢到了,之后也睡眠了。那三个线程会陆陆续续醒来,继续抢夺CPU往下执行。现在线程1醒了,抢到了CPU,线程1继续执行ticket++,ticket=1,但是还没来得及打印呢,CPU执行权就被线程2抢走了,2也执行ticket++,ticket = 3。线程执行代码的时候,CPU执行权随时都会被其他线程抢走。现在线程3又抢到了,执行ticket++,ticket = 3。所以不管哪个线程打印,都打印的3张票,这就是重复票的由来,这根本原因就是线程在执行的时候,具有随机性。
当ticket = 99时,三个线程都处于睡眠状态,线程1醒了开始执行,ticket = 100,还没来得及打印,CPU执行权被线程2抢走,ticket++,ticket = 101。最后线程3抢到,ticket++,ticket = 102。所以最后打印的都是102号票,这就是超出范围的由来,还是由于线程执行的时候具有随机性。
那怎么改呢?假设我能把操作数据的这段代码给锁起来,当有线程进来之后,就算有其他线程抢到CPU执行权,也得在外面等着,进不来。只有当操作共享数据的线程结束后,其他线程才能继续操作。简单来说就是要把共享数据的代码给锁起来,让这些线程轮流执行,就不会出现问题了。
同步代码块
这个锁就被我们称之为同步代码块,把操作共享数据的代码锁起来。
格式:
synchronized(锁){
操作共享数据的代码
}
小括号中的是锁对象,锁对象可以是任意的,但一定要保证这个对象是唯一的
特点1:锁默认打开,有一个线程进去了,锁自动关闭
特点2;里面所有代码执行完毕,线程出来了,锁才会自动打开
ublic class MyThread extends Thread { //表示这个类所有的对象,都共享ticket数据 static int ticket = 0;//0 ~ 99 //锁对象是任意的但一定是唯一的,用static修饰即可 static Object obj = new Object(); @Override public void run() { while (true) { //小括号里要写锁对象 synchronized (obj) { //同步代码块 if (ticket < 100) { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } ticket++; System.out.println(getName() + "正在卖第" + ticket + "张票!!!"); } else { break; } } } } } public class ThreadDemo { public static void main(String[] args) { /* 需求: 某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟该电影院卖票 */ //创建线程对象 MyThread t1 = new MyThread(); MyThread t2 = new MyThread(); MyThread t3 = new MyThread(); //起名字 t1.setName("窗口1"); t2.setName("窗口2"); t3.setName("窗口3"); //开启线程 t1.start(); t2.start(); t3.start(); } }
小细节注意一下,同步代码块synchronized不能写在循环外面,要不然会导致一个线程把所有票卖完才出来的情况,也就是一个线程把while都执行完了才会出来,其他线程没机会卖。第二个小细节是synchronized小括号里的锁对象一定是唯一的,如果不是唯一的,那有两条线程,他们的是不同的锁,那这锁还有意义吗,根本锁不起来了,synchronized同步代码块就没有任何意义。就像下面这段,synchronized中的对象是this,this的含义就是哪个线程进来了他就代表哪个线程对象本身,所以这个锁对象就是不一样的。
while (true) { //小括号里要写锁对象 synchronized (this) { //同步代码块 if (ticket < 100) { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } ticket++; System.out.println(getName() + "正在卖第" + ticket + "张票!!!"); } else { break; } }
一般这个锁对象,我们就用类名.class表示,这里就是MyThread.class,表示当前类的字节码文件对象,这个对象就是唯一,因为在同一个文件夹中,只能有一个MyThread.class。
同步方法
另一种起到线程安全的方法叫做同步方法,可以把整个方法中所有代码都锁起来。把synchronized写在修饰符后面就行,锁住方法里的所有代码,但是这个同步方法不能指定锁对象,是java指定好的。锁对象有两种情况,一个是当前方法为非静态,那么他的锁对象为this,就是当前方法的调用者。另一种是方法为静态方法,那么锁对象就是当前类的字节码文件对象,比如刚刚的MyThread.class。
我们在写同步方法的时候,可能会有一个小疑问,就是不知道什么代码要写进同步方法中,可以用这种技巧:先不写同步方法,先写同步代码块,再把同步代码块里的代码抽取成方法即可。
public class MyRunnable implement Runnable{ //这里就不用static修饰了,之前我们通过创建线程类继承Thread的方式创建线程,通过一个线程类创建多个线程对象,这几个对象要共享ticket,ticket属于线程类。但现在我们是通过实现Runnable接口这种方式创建线程对象,我们只需要创建一次MyRunnable作为参数,所以就不需要用static修饰ticket了。 int ticket = 0; @Override public void run(){ //1.循环 //2.同步代码块(同步方法) //3.判断共享数据是否到了末尾,如果到了末尾怎么办 //4.如果没到末尾怎么办 whiel(true){ synchronized(MyRunnable.class){ if(ticket == 100){ break; }else{ try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } ticket++; System.out.println(Thread.currentThread.getName()+"在卖第"+ticket+"张票!"); } } } } } public class ThreadDemo(){ MyRunnable mr = new MyRunnable(); Thread t1 = new Thread(mr); Thread t2 = new Thread(mr); Thread t3 = new Thread(mr); t1.setName("窗口1"); t2.setName("窗口2"); t3.setName("窗口3"); t1.start(); t2.start(); t3.start(); }
然后我们把同步代码块中的代码抽取成方法,在IDEA中选中代码,按ctrl+alt+m,就能生成方法。方法是非静态的,锁对象就是this,而这个this就是在测试类中创建的MyRunnable类的对象mr,mr是唯一的。
public class MyRunnable implements Runnable { int ticket = 0; @Override public void run() { //1.循环 while (true) { //2.同步代码块(同步方法) if (method()) break; } } //this private synchronized boolean method() { //3.判断共享数据是否到了末尾,如果到了末尾 if (ticket == 100) { return true; } else { //4.判断共享数据是否到了末尾,如果没有到末尾 try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } ticket++; System.out.println(Thread.currentThread().getName() + "在卖第" + ticket + "张票!!!"); } return false; } }
延伸一下,我们用以前用过StringBuilder来拼接对象,还有另一个拼接对象的类叫StringBuffer,两个有什么区别呢?当打开API帮助文档后会惊奇的发现,两个类的方法是一样的,但是在StringBuilder中写着,它是线程不安全的,如果想同步最后用StringBuffer。打开两者的原码会发现,StringBuffer中每一个方法都有synchronized修饰,而StringBuilder是没有这个修饰的,所以StringBuffer是线程安全的。那之后我们该如何选择使用这两个类呢?如果我们是单线程执行,不需要考虑多线程的数据安全性,我们就用StringBuilder;如果是多线程的环境下,需要考虑数据安全性,就用StringBuffer。
刚刚我们用同步代码块的时候,它是自动上锁自动关锁,那有没有锁是可以手动加手动关呢?必须有的,Lock锁就是这样的,这也是一种锁对象,比synchronized有更广泛的锁定操作。
Lock锁
Lock中具有获得锁和释放锁的方法:手动上锁、手动释放锁
- void lock():获得锁
- void unlock():释放锁
注意的是Lock是一个接口不能直接实例化,要想创建对象要采用他的实现类ReentrantLock实例化,直接用其空参构造。
public class MyThread extends Thread{ static int ticket = 0; //这里采用继承Thread类的方式,需要创建很多次MyThread,势必会造成lock锁会有很多个,所以要加staitc,表示只有一个锁,所有对象共享通一把锁。 //创建Lock对象 static Lock lock = new ReentrantLock(); @Override public void run() { //1.循环 while(true){ //2.同步代码块 //synchronized (MyThread.class){ lock.lock(); //2 //3 try { //3.判断 if(ticket == 100){ break; //4.判断 }else{ Thread.sleep(10); ticket++; System.out.println(getName() + "在卖第" + ticket + "张票!!!"); } // } } catch (InterruptedException e) { e.printStackTrace(); } finally { lock.unlock();//保证锁一定会被释放,为什么一定要被释放掉呢。假如有三个线程123,当ticket=100的时候,三个线程全在while里,而且是1抢到了资源戴上了锁,现在1进行if判断,然后break退出了循环,假设我们没有finally代码块,那是不是lock.unlock();就不会被执行,也就是说退出循环之后,锁还在1身上。但是,此时线程2、3还在循环里等着拿锁呢,可是所都出去了,他俩就被困在里面出不来,所以程序就不会停止,因此我们必须要保证上锁之后要解锁,因此就在一个finally代码块,保证会解锁。 } } } } public class ThreadDemo { public static void main(String[] args) { /* 需求: 某电影院目前正在上映国产大片,共有100张票,而它有3个窗口卖票,请设计一个程序模拟该电影院卖票 用JDK5的lock实现 */ MyThread t1 = new MyThread(); MyThread t2 = new MyThread(); MyThread t3 = new MyThread(); t1.setName("窗口1"); t2.setName("窗口2"); t3.setName("窗口3"); t1.start(); t2.start(); t3.start() } }
死锁
这部分我们来学习一下死锁,死锁就是发生了所嵌套,注意奥这是错误,我们学习他是为了不犯这个错误。描述一下这个死锁就是A线程拿到了A锁,B线程拿到了B锁,他们都在等着对方释放锁,这就卡住了,谁都动弹不得。下面代码就出现死锁现象了,A抢到A锁,B抢到B锁,A想释放A锁就必须得到B锁,B想释放B锁就必须得到A锁,关键是A锁在A手里,B锁在B手里。以后写的时候别让锁嵌套起来就行。
public class MyThread extends Thread { static Object objA = new Object(); static Object objB = new Object(); @Override public void run() { //1.循环 while (true) { if ("线程A".equals(getName())) { //1.此时A抢到了CPU并打开了A锁 synchronized (objA) { System.out.println("线程A拿到了A锁,准备拿B锁");//A //2.假如此刻线程B抢到了CPU,就会到else if中,得到了B锁 synchronized (objB) { System.out.println("线程A拿到了B锁,顺利执行完一轮"); } } } else if ("线程B".equals(getName())) { if ("线程B".equals(getName())) { //3.B锁此时也打开了 synchronized (objB) { System.out.println("线程B拿到了B锁,准备拿A锁");//B synchronized (objA) { System.out.println("线程B拿到了A锁,顺利执行完一轮"); } } } } } } }
等待唤醒机制
兄弟们一起继续往下学习多线程中的生产者和消费者,也被叫做等待唤醒机制,是多线程中十分经典的多线程协作模式。那什么是等待唤醒机制呢?我们知道线程的执行时具有随机性的,等待唤醒机制就会打破随机性,会让线程轮流执行,你一次我一次。
两条线程其中一条被称为生产者,用于生产数据。另一条为消费者,消费数据。假设现在有两个人,一个是吃货,负责吃,就是消费者。另一个是厨师,负责做,就是生产者。还需要有第三者桌子,在默认情况下线程的执行是具有随机性的,咱们就需要有一个东西去控制线程的执行,我们现在就用桌子控制,利用桌子来控制线程的执行。假如桌子上有一碗面条,那就是吃货执行。负责吃,那要是没有面条的话,就是厨师执行,负责做。理想情况就是一开始桌子上没面条,厨师抢到CPU执行权,做一碗面条放在桌子上,然后吃货抢到CPU执行权,把面条吃了。
但是肯定不会这么理想啊,会有两种情况出现。第一种,消费者等待。最开始是吃货先抢到了,但是桌子上没吃的啊,就只能等着,在代码中叫做wait,在等待过程中执行权一定会被厨师抢到,做一碗面放桌子上。当然这还没完,因为吃货在等待,厨师要唤醒吃货,代码中叫做notify。这第一种情况主要就看桌子,没面条消费者就得等待。第二种是生产者等待,最开始是初始先抢到,此时桌子上没东西,厨师就做一碗面,放桌子上,notify消费者,就算没人等待,notify一下也没关系。下一次还是厨师抢到执行权,他就不能做面条了,因为桌子上已经有了,他只能等着,此时处于wait。CPU的执行权到了吃货那里,他会判断桌子上是否有食物,如果有就吃,并且notify厨师,没有就等待。
消费者代码逻辑如下:
- 判断桌子上是否有食物
- 如果没有就等待
- 如果有就开吃
- 吃完之后,唤醒厨师继续做
生产者代码逻辑如下:
- 判断桌子上是否有食物
- 如果有就等待
- 如果没有就做
- 把食物放桌子上
- 叫醒等待的吃货
这里涉及三个方法:
- public void wait():当前线程等待,直到被其他线程唤醒
- void notify():随机唤醒单个线程
- void notifyAll():唤醒所有线程 等待线程比较多的时候就用这个
public class Desk { /* * 作用:控制生产者和消费者的执行 * * */ //是否有面条 0:没有面条 1:有面条 public static int foodFlag = 0; //总个数 public static int count = 10; //锁对象 public static Object lock = new Object(); } public class Foodie extends Thread{ @Override public void run(){ /* 1.循环 2.同步代码块 3.判断共享数据是否到了末尾(先判断已经到了末尾) 4.判断共享数据是否到了末尾(没到末尾) */ while(true){ sychronized( { if(Desk.count == 0){ break; }else{ //先判断桌子上是否有面条 if(Desk.foodFlag == 0){ //如果没有,就等待 try { Desk.lock.wait();//让当前线程跟锁进行绑定 } catch (InterruptedException e) { e.printStackTrace(); } }else{ //把吃的总数-1 10 Desk.count--; //如果有,就开吃 SYstem.out.println("吃货在吃面条,还能吃"+Desk.count+"碗!!!"); //吃完之后,唤醒厨师继续做 //用锁对象调用notifyAll(),表示要唤醒绑定在这把锁上面的所有线程 Desk.lock.notifyAll(); //修改桌子状态 Desk.foodDlag = 0; } } } } } } public class Cook extends Thread{ @Override public void run(){ /* 1.循环 2.同步代码块 3.判断共享数据是否到了末尾(到了末尾) 4.判断共享数据是否到了末尾(没到末尾) */ while(true){ sychronized(Desk.lock){ id(Desk.count == 0){ //吃货吃不下了 break; }else{ //厨师的核心逻辑 //判断桌子上是否有食物 if(Desk.foodFlag == 1){ try { //如果有就等待 当前线程和锁绑定起来,一旦绑定之后,唤醒的时候就知道唤醒什么线程了 Desk.lock.wait(); } catch (InterruptedException e) { e.printStackTrace(); } }else{ //如果没有就做 System.out.println("厨师做了一碗面条"); //修改食物的状态 Desk.foodFlag = 1; //叫醒等待的消费者开吃 Desk.lock.notifyAll(); } } } } } } public class ThreadDemo { public static void main(String[] args) { /* * * 需求:完成生产者和消费者(等待唤醒机制)的代码 * 实现线程轮流交替执行的效果 * * */ //创建线程的对象 Cook c = new Cook(); Foodie f = new Foodie(); //给线程设置名字 c.setName("厨师"); f.setName("吃货"); //开启线程 c.start(); f.start(); } }
咱们已经学习完等待唤醒机制的最基本写法,还有一种写法是阻塞队列方式实现。那什么是阻塞队列呢,就像连接生产者和消费者之间的管道,厨师做好面之后就可以放进管道里,吃货从管道里拿出来吃。而且我们可以规定管道中最多可以放多少碗面条。如果最多只有一万,就是跟刚刚一样,做一碗吃一碗。
阻塞队列里的队列,就是说在管道里,面就是在排队,先进先出。阻塞是什么意思呢?put数据时,放不进去,会等着,也叫作阻塞。take数据时,去除第一个数据,取不到也会等着,也叫阻塞。
阻塞结构的体系结构来看下,一共实现了四个接口。最顶层是Iterable,迭代器接口,阻塞队列可以用迭代器或者增强for遍历。本身也实现了Collection,也是一个单列结合。Queue表示是队列,BlockingQueue是阻塞队列。我们要创建的是两个实现类对象。
public class Cook extends Thread{ ArrayBlockingQueue<String> queue; public Cook(ArrayBlockingQueue<String> queue){ this.queus = queus; } @Override public void run(){ while(true){ //不断的把面条放进队列中 //这里不需要加锁的,put方法底层内置了锁 /* public void put(E e) throws InterruptedException { checkNotNull(e); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { //比较队列中的个数和队列长度是否一致 while (count == items.length) //装满了就等待 notFull.await(); //没装满就放数据 enqueue(e); } finally { //所有逻辑都操作完成就释放锁 lock.unlock(); } } */ try { queue.put("面条"); System.out.println("厨师放了一碗面条"); } catch (InterruptedException e) { e.printStackTrace(); } } } } public class Foodie extends Thread{ ArrayBlockingQueue<String> queue; public Foodie(ArrayBlockingQueue<String> queue){ this.queus = queus; } @OVerride public void run(){ while(true){ //千万别再写synchronized,外面一层锁里面一层锁,容易死锁 //不断从阻塞队列中获取面条 /* take的底层逻辑 public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == 0) notEmpty.await(); return dequeue(); } finally { lock.unlock(); } } */ try { String food = queue.take(); System.out.println(food);//把输出语句写在锁的外面就会在输出的时候出现一点点问题,不过没关系,数据是对的。 } catch (InterruptedException e) { e.printStackTrace(); } } } } public class ThreadDemo { public static void main(String[] args) { /* 细节:生产者和消费者必须使用同一个阻塞队列 */ //1.创建阻塞队列的对象 ArrayBlockingQueue<String> queue = new ArrayBlockingQueue<>(1); //2.创建线程的对象,并把阻塞队列传递过去 Cook c = new Cook(queue); Foodie f = new Foodie(queue); //3.开启 c.start(); f.start(); } }
线程的状态
之前的线程状态我们学过了,但是不完整,下面是完整的过程。真正在Java中只有六种状态,是没有运行状态的,因为你代码已经运行了就可以不用管了,但是为了方便理解就可以自己知道还有一个运行状态就行。
新建状态(NEW)---> 创建线程对象
就绪状态(RUNNABLE)---> start方法
阻塞状态(BLOCKED)---> 无法获得锁对象
等待状态(WAITING)---> wait方法
计时等待(TIME_WAITING)---> sleep方法
结束状态(TERMINATED)---> 全部代码运行完毕
多线程的内存图
我们来看看下面代码执行的时候,内存图是怎么样的。
我们要记住一件事,main方法就处于main线程中的,代码启动后,并不只是开了两条线程,而是三条,还要算上main方法。
在Java的内存图中,堆内存是唯一的,但是栈内存不唯一,而是跟线程有关系,简单来说每一个线程都有自己的栈,也称为线程栈,以前我们画的栈都是main线程的栈,而程序的入口,主方法main就是运行在main线程中的。这个内存图顺序是这样的,当执行main方法的时候,会开启main线程的栈,main()进入栈中,然后创建两个MyThread对象,存入到堆内存中。之后t1和t2线程启动,就又多了两个栈,线程1的栈和线程2的栈,两个线程中的run方法和局部变量b全部进栈。
线程池
现在我们来学习下一个知识点,线程池。之前我们写多线程的时候,需要一个线程我们就创建一个线程,代码跑完线程消失,但是这种方式是不对的,会浪费操作系统的资源。我们准备一个容器,用来存放线程,这个容器被称为线程池。最开始线程池是空的,当给线程池一个任务的时候,线程池就会自动创建一个线程,拿着这个线程执行任务,执行完之后再把这个线程还给线程池。当第二次再提交任务的时候,就不会再去创建线程池了,而是用刚才的,用完再放回去,这就是多线程的核心原理。假如执行第一个任务,还没执行完,第二个就来了,怎么办?线程池就会再创建一个线程。线程池有上限,可以自己设定。假如设置为3,可以同时执行三个任务,后面再来任务就要排队了。
线程池代码实现过程:
- 创建线程池
- 提交任务
- 所有任务执行完,关闭线程池。实际开发中线程池不会关闭
Excutors:线程池的工具类,通过调用方法返回不同的线程池对象
public class MyThreadPoolDemo{ public static void main(String[] args){ /* public static ExecutorService newCachedThread():创建一个没有上限的线程池 public static ExecutorService newFixedThreadPool(int nThreads):创建有上限的进程池 */ //1.获取线程池对象 ExecutorService pool1 = Executors.newCachedThreadPool(); //2.提交任务 //提交4个任务,就是创建四个线程执行任务 pool1.submit(new MyRunnable()); pool1.submit(new MyRunnable()); pool1.submit(new MyRunnable()); pool1.submit(new MyRunnable()); //3.销毁线程池 pool1.shutdown(); } } public class MyRunnable implements Runnable{ @Override public void run(){ for(int i = 1;i <= 100;i++){ System.out.println(Thread.currentThread().getName()+"---"+i); } } }
线程池主要核心原理:
- 创建一个线程池,是空的
- 当提交任务时,线程池会创建一个新的线程对象,任务执行完毕,线程归还池子,下回再提交任务时,不需要创建新的线程,直接复用。
- 如果提交任务的时候,池中没有空闲线程,而且无法创建新的线程,任务就会排队等待。
自定义线程池
ExecutorService创建的线程池,不够灵活。当提交的任务比较多,任务就会排队等待,如果说我想修改队伍长度,就修改不了,所以就想能不能不用工具类,自己创建线程池对象呢?这样就能对参数进行设置。这样我们就可以使用自定义线程池,ThreadpoolExecutor类。
核心元素一:核心线程数量(除此之外还有临时线程)不能小于0
核心元素二:线程池中最大线程的数量 最大数量>=核心线程数
核心元素三:空闲时间(值)空闲时间到了临时线程要被销毁 不能小于0
核心元素四:空闲时间(单位)用TImeUnit指定
核心元素五:阻塞队列 不能为null
核心元素六:创建线程的方式(线程工厂) 不能为null
核心元素七:要执行的任务过多时的解决方案(任务拒绝策略) 不能为null
例如,刚开始线程池为空,现在设置核心线程为3,临时线程也为3,所以一共是6个线程,最多只有6个。核心线程就跟正式员工一样,不会被销毁,而临时线程就像临时员工,一段时间不工作,就会被销毁。现在往线程池提交任务,提交三个,会创建3个线程。那要是提交5个呢?池会创建3个核心线程来处理,剩余两个任务会排队等待,等待前面的执行完成。那假如有八个呢,还是会等待,如果这时设置队伍长度为3,有两个线程连队伍都排不上,这是线程池才会创建临时线程处理任务。
小细节:什么时候回创建临时线程?核心线程都在忙,而且队伍排满了,这是创建临时线程。任务一定是按照提交顺序执行的吗?不是的。
那如果提交的任务为10,已经超过了核心线程数+临时线程数+队伍长度,该怎么办?所有都满了之后,还剩一个,会触发任务拒绝策略。拒绝策略默认是舍弃不要,一共有四种,其他的了解可。
public class MyThreadPoolDemo1 { public static void main(String[] args){ ThreadPoolExecutor pool = new ThreadPoolExecutor( corePoolSize:3, //核心线程数量,不能小于0 maximumPoolSize:6, //最大线程数,>=corePoolSize keepAliveTime:60, //空闲线程最大存活时间 TimeUnit.SECONDS, //空闲线程最大存活时间单位 new ArrayBlockingQueue<>(3), //任务队列 Executors.defaultThreadFactory(), //创建线程工厂 new ThreadPoolExecutor.AbortPolicy() //任务的拒绝策略 ); //下面就提交任务即可 } }
不断提交任务,会有以下三个临界点:
- 当核心线程满了,再提交任务就要排队
- 当核心线程满,队伍满时,会创建临时线程
- 当核心线程满,队伍满,临时线程也满后,会触发任务拒绝策略
那这个线程池多大合适呢?是有公式的,看着很复杂,一点点拆分。
先来解释一下什么是最大并行数。举个例子,一个间谍能窃取一个情报,那同时派出四个间谍就能窃取四个情报了,间谍可以分身,一分为二,所以有八个人,同时做八件事情。例如我们的CPU是4核8线程,4核就是CPU有四个大脑,能同时并行做四件事,但是发明家创造了超线程技术,可以把原来的4个大脑虚拟成8个,就是8线程,最大的并行数为8。可以通过任务管理器中的性能看看自己电脑的线程数,我的是2核四线程。
线程池多大合适呢?看项目时什么类型的,分为两种,一种是CPU密集型运算,另一种是I/O密集型运算。假设项目中计算比较多,但是读取本地文件和数据库文件比较少,就属于CPU密集型运算,根据公式计算即可。如果项目读取本地文件操作比较多,或者读取数据库操作比较多,那就是I/O密集型项目,大多数项目都是I/O密集型。公式中的总时间怎么计算,比如要从本地文件中,读取两个数据并进行相加,包括两步操作:
操作一:读取两个数据(1s)
操作二:相加(1s)
读取是跟硬盘有关系,跟CPU没关系,只有下面的相加是跟CPU有关系的,假设两个操作各耗时1s,套用公式就可以认为是(100%/50%)。所以4核8线程的最大线程数就是8 * 100% * (100%/50%)。
这篇文章到这就结束了,但这不是线程的结束,这里写的是线程的常用基本操作,还有很多操作是面试常问的,下一篇文章我会整理,跟大家一起学习,下篇见啦。
#牛客在线求职答疑中心##牛客解忧铺##如何判断面试是否凉了##你的简历改到第几版了#我是一个转码的小白,平时会在牛客中做选择题,在做题中遇到不会的内容就会去找视频或者文章学习,以此不断积累知识。这个专栏主要是记录一些我通过做题所学到的基础知识,希望能对大家有帮助