JavaSE笔记:进阶篇

本篇内容紧跟开发基础笔记, 主要介绍多线程、网络编程、单元测试、反射、注解。本文不定期更新,转载请注明出处。

Copyright © 2020 AnonyEast, All rights reserved.

第1课 线程

在前面的学习中,所有的程序都是通过单线程来实现的,程序都是从main()方法入口开始执行到程序结束,整个过程只能顺序执行,如果程序在某个地方出现问题,那么整个程序就会崩溃,所以这就说明了单线程在某些方面的脆弱性和局限性。这种单线程就好比是售票大厅只开设了一个售票窗口,所有人只能在这个窗口排队买票,整个过程虽然可以实现卖票,但是效率非常低;如果此时开设了多个售票窗口同时卖票,不仅可以提高售票效率而且还可以进一步提升用户体验,多个售票窗口就相当于程序设计中的多线程。

一、并发与并行

  • 并发:指两个或多个事件在同一个时间段内发生。
  • 并行:指两个或多个事件在同一时刻发生(同时发生)。

在操作系统中,安装了多个程序,并发指的是在一段时间内宏观上有多个程序同时运行,这在单核单线程 CPU 系统中,每一时刻只能有一个程序执行,即微观上这些程序是分时的交替运行,只不过是给人的感觉是同时运行,那是因为分时交替运行的时间是非常短的

而在多核多线程 CPU 系统中,则这些可以并发执行的程序便可以分配到多个处理器(CPU)核心上,实现多任务并行执行,即利用每个核心来处理一个可以并发执行的程序,这样多个程序便可以同时执行。目前电脑市场上说的多核 CPU,便是多核处理器,核越多,并行处理的程序越多,能大大的提高电脑运行的效率。

二、线程与进程

  • 进程:是指一个内存中正在运行的应用程序,每个进程都有一个独立的内存空间,一个应用程序可以同时运行多个进程;进程也是程序的一次执行过程,是系统运行程序的基本单位;系统运行一个程序即是一个进程从创建、运行到消亡的过程。
  • 线程:线程是进程中的一个执行单元,负责当前进程中程序的执行,一个进程中至少有一个线程。一个进程中是可以有多个线程的,来同时完成多个任务,这个应用程序也可以称之为多线程程序。
  • 简而言之:一个程序运行后至少有一个进程,一个进程中可以包含多个线程

1.进程

我们可以在电脑底部任务栏,右键->打开任务管理器,可以查看当前系统中的进程。

2.线程

CPU:中央处理器,对数据进行计算,指挥电脑中软件和硬件干活

CPU的核心和线程:例如4核心8线程的CPU,可以同时并行执行8个程序

以某流氓管家为例,该软件有多个功能,但这些功能可以被分为多个线程同时运行

3.线程调度

(1)分时调度

所有线程轮流使用 CPU 的使用权,平均分配每个线程占用 CPU 的时间。

(2)抢占式调度

优先让优先级高的线程使用 CPU,如果线程的优先级相同,那么会随机选择一个(线程随机性),Java程序使用的是抢占式调度。 线程优先级可以在任务管理器设置。

  • 抢占式调度详解

大部分操作系统都支持多进程并发运行,现在的操作系统几乎都支持同时运行多个程序。比如:一边登录着QQ,一边开着浏览器,一边开着IDEA,一边开着钉钉。此时,这些程序是在同时运行,感觉这些软件好像在同一时刻运行着。

实际上,CPU(中央处理器)使用抢占式调度模式在多个线程间进行着高速的切换。对于CPU的一个核而言,某个时刻,只能执行一个线程,而CPU的在多个线程间切换速度相对我们的感觉要快,看上去就是在同一时刻运行。其实,多线程程序并不能提高程序的运行速度,但能够提高程序运行效率,让CPU的使用率更高。

三、主线程

1.主线程:执行main方法的线程。

2.单线程程序:Java程序中只有一个线程,执行从main方法开始,从上到下依次执行。

  • JVM执行main方法,main方法会进入到栈内存
  • JVM会找操作系统开辟一条main方法通向cpu的执行路径
  • CPU就可以通过这个路径来执行main方法
  • 而这个路径有一个名字,叫main线程(主线程)

3.单线程的缺陷: 程序在某个地方出现问题,整个程序就会崩溃。例如中间出现异常,但是没有处理,则整个程序终止。

4. 在Java中,每次程序运行至少启动2个线程。一个是main线程,一个是垃圾收集线程。因为每当使用 java命令执行一个类的时候,实际上都会启动一个JVM,每一个JVM其实在就是在操作系统中启动了一个进 程。

第2课 Thread类

java.lang.Thread类:是描述线程的类,我们想要实现多线程程序,就必须继承Thread类。

一、Thread类实现多线程的步骤

  • 创建一个Thread类的子类
  • 在Thread类的子类中重写Thread类中的run方法,设置线程任务(开启线程要做什么)
  • 创建Thread类的子类对象
  • 调用Thread类中的方法start方法,开启新的线程,执行run方法

1.void start()方法使该线程开始执行JVM调用该线程的run方法

2.结果是两个线程并发地运行:当前线程(main线程)和另一个线程(创建的新线程,执行其 run 方法)同时运行。

3.多次启动一个线程是非法的。特别是当线程已经结束执行后,不能再重新启动。

4.Java程序属于抢占式调度,那个线程的优先级高,那个线程优先执行。同一个优先级,随机选择一个执行。

class MyThread1 extends Thread {
    //定义指定线程名称的构造方法
    public MyThread1(String name) {
        //调用父类的String参数的构造方法,指定线程的名称
        super(name);
    }
    //在Thread类的子类中重写Thread类中的run方法,设置线程任务
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.print(getName() + "正在执行 ");
        }
    }
}

public class ThreadTest {
    public static void main(String[] args) {
        //创建Thread类的子类对象
        MyThread1 thread = new MyThread1("新的线程");
        //执行Thread类中的start方法,创建新的线程,执行run方法
        thread.start();
        //主方法中执行for循环
        for (int i = 0; i < 10; i++) {
            System.out.print("主线程正在执行 ");
        }
    }
}

//通过匿名内部类和匿名对象更加简洁
        new Thread(){
            @Override
            public void run() {
                System.out.println("Hello World");
            }
        }.start();//Hello World
//输出结果:
//主线程正在执行 主线程正在执行 主线程正在执行 新的线程正在执行 Hello World
//新的线程正在执行 新的线程正在执行 新的线程正在执行 新的线程正在执行 
//主线程正在执行 主线程正在执行

可以看到MyThread1线程和主线程在交互运行,而不是从上到下先执行MyThread的start方法结束后再执行"主线程正在执行"。

二、Thread类实现多线程的原理

(1)为什么多线程会随机打印不同的结果

执行start方法时,会多开辟一条通向CPU的路径,这样CPU就有了选择的权利,他可以选择执行main线程,也可以选择执行我们自己创建的新的线程。

(2)多线程的内存图

每一个执行线程都有一片自己所属的栈内存空间。每运行一个Thread对象的start方法,就会开辟新的栈内存空间,新开辟的栈空间中存储着run方法,CPU可以从多个栈空间中选择性执行。

这样做的好处是:多个线程之间互不影响,因为他们在不同的栈空间中。

三、Thread类的常用方法的使用

1.常用方法

(1)构造方法

  • public Thread():分配一个新的线程对象。
  • public Thread(String name):分配一个指定名字的新的线程对象。
  • public Thread(Runnable target):分配一个带有指定目标新的线程对象。
  • public Thread(Runnable target,String name):分配一个带有指定目标新的线程对象并指定名字。

(2)成员方法和静态方法

  • public String getName():获取当前线程名称。
  • public void start():导致此线程开始执行; Java虚拟机调用此线程的run方法。
  • public void run()此线程要执行的任务在此处定义代码。
  • public static void sleep(long millis):使当前正在执行的线程以指定的毫秒数暂停(暂时停止执行)。
  • public static Thread currentThread()返回对当前正在执行的线程对象的引用。
  • void setDaemon(boolean on):将该线程设置为后台线程(守护线程)或前台线程(用户线程)。 true为后台,false为前台。
  • boolean isDaemon():判断该线程是否为后台线程。

2.方法的使用

(1)获取线程名称

1.使用Thread类中的方法getName()方法返回该线程的名称。

2.对于没有继承Thread类的方法,可以先使用Thread类的静态方法currentThread()获取到当前正在执行的线程的引用,再使用Thread类的成员方法方法getName()获取线程的名称。

class MyThread2 extends Thread{
    @Override
    public void run() {
        System.out.println(getName());
    }
}
public class GetThreadNameTest {
    public static void main(String[] args) {
        //创建Thread类的子类对象
        MyThread2 mt = new MyThread2();
        //调用start方法创建新的线程,执行run方法
        mt.start();//Thread-0
        new MyThread2().start();//Thread-1
        new MyThread2().start();//Thread-2

        //对于没有继承Thread类的方法,要先调用currentThread()方法获取当前执行的线程对象的引用
        //再执行getName方法
        System.out.println(Thread.currentThread().getName());//main
    }
}

(2)设置线程名称

方法一:使用Thread类中的方法setName(名字)

void setName(String name) 改变线程名称,使之与参数 name 相同。

方法二:建一个带参数的构造方法,参数传递线程的名称;调用父类的带参构造方法,把线程名称传递给父类,让父类(Thread)给子线程起一个名字

Thread(String name) 分配新的 Thread 对象。

class MyThread3 extends Thread {
    @Override
    public void run() {
        System.out.println(getName() + "线程开始执行");
    }

    //构造方法:指定线程名称
    public MyThread3(String name) {
        super(name);
    }

    public MyThread3() {
    }
}
public class SetThreadNameTest {
    public static void main(String[] args) {
        //构造方法传入线程名称
        new MyThread3("XHH").start();//XHH线程开始执行
        //setName方法指定线程名称
        MyThread3 myThread = new MyThread3();
        myThread.setName("YDD");
        myThread.start();//YDD线程开始执行
    }
}

(3)暂停线程执行

public static void sleep(long millis):使当前正在执行的线程以指定的毫秒数暂停(暂时停止执行),毫秒数结束之后,线程继续执行。

调用此方法可能抛InterruptedException异常,该异常必须处理。因此要么用try...catch语句处理要么通过throws关键字声明异常。

可以用于模拟秒表,代码如下

public class SleepThreadTest {
    public static void main(String[] args) throws InterruptedException {
        //模拟秒表
        for (int i = 5; i > 0; i--) {
            System.out.println("倒计时:" + i + "秒");
            //线程睡眠1000毫秒
            Thread.sleep(1000);
        }
    }
}
//输出结果
//倒计时:5秒 倒计时:4秒 倒计时:3秒 倒计时:2秒 倒计时:1秒

第3课 Runnable接口

上一课中,虽然我们通过继承Thread类的方式实现了多线程,但这种方式有一定的局限性,因为Java只支持类的单继承,如果某个类已经继承了其他父类,就无法再继承Thread类来实现多线程。在这种情况下,就可以考虑通过实现Runnable接口的方式来实现多线程。

一、Runnable接口

1.java.lang.Runnable接口:这是一个函数式接口,规定里面只有一个run方法。
那么使用该接口只需要让一个类实现Runnable接口,并且需要覆写run方法。当然,用匿名内部类也是可以的。

2.使用Thread类的构造方法

  • Thread(Runnable target):传入Runnable接口并分配新的 Thread 对象。
  • Thread(Runnable target, String name):传入Runnable接口分配新的Thread对象并指定线程名称。

二、Runnable接口实现多线程的步骤

  • 创建一个Runnable接口的实现类
  • 在实现类中重写Runnable接口的run方法,设置线程任务
  • 创建一个Runnable接口的实现类对象
  • 创建Thread类对象,构造方法中传递Runnable接口的实现类对象
  • 调用Thread类中的start方法,开启新的线程执行run方法

也可以用匿名内部类和匿名对象以及Lambda表达式

//1.创建一个Runnable接口的实现类
class RunnableImpl implements Runnable {
    //2.在实现类中重写Runnable接口的run方法,设置线程任务
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "正在执行");
    }
}

public class RunnableTest {
    public static void main(String[] args) {
        //3.创建一个Runnable接口的实现类对象
        RunnableImpl runnable = new RunnableImpl();
        //4.创建Thread类对象,构造方法中传递Runnable接口的实现类对象
        Thread thread = new Thread(runnable);
        //5.调用Thread对象的start方法,开启新的线程执行run方法
        thread.start();//Thread-0正在执行

        //通过匿名内部类和匿名对象
        Thread helloWorld = new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("Hello World");
            }
        });
        helloWorld.start();//Hello World

        //通过Lambda表达式
        Thread helloLambda = new Thread(() -> System.out.println("Hello Lambda"));
        helloLambda.start();//Hello Lambda
    }
}

三、Runnable接口创建多线程程序的好处

1.避免了单继承的局限性

  • 一个类只能继承一个类(一个人只能有一个亲爹),类继承了Thread类就不能继承其他的类。
  • 实现了Runnable接口,还可以继承其他的类,实现其他的接口。

2.增强了程序的扩展性,降低了程序的耦合性(解耦)

  • 实现Runnable接口的方式,把设置线程任务和开启新线程进行了分离(解耦),通过传递不同的接口,实现不同的线程操作
  • 实现类中,重写了run方法:用来设置线程任务
  • 创建Thread类对象,调用start方法:用来开启新线程

3. 线程池只能放入实现Runable或Callable类线程,不能直接放入继承Thread的类。

4. 适合多个相同的程序代码的线程去共享同一个资源。 例如有多个购票窗口,但是余票的数量要被这几个购票窗口共享,就需要使用Runnable接口。后面有一个案例专门讲资源共享。

四、Runnable接口实现共享资源的案例

以购票为例,假设有4个购票窗口,共有10张票,如果用Thread类实现多线程,则假设每个Thread对象是一个购票窗口,可以得到以下代码。

class TicketWindow extends Thread {
    int tickets = 10;//共10张票
    @Override
    public void run() {
        while(tickets>0) {
            System.out.printf("%s窗口第%d张票已经售出\n", Thread.currentThread().getName(), tickets--);
        }
    }
}

public class TicketThreadTest {
    public static void main(String[] args) {
        new TicketWindow().start();
        new TicketWindow().start();
        new TicketWindow().start();
        new TicketWindow().start();
    }
}
//部分输出结果:
//Thread-0窗口第10张票已经售出
//Thread-3窗口第10张票已经售出
//Thread-2窗口第10张票已经售出
//Thread-2窗口第9张票已经售出

可以看到,有多个窗口卖出了第10张票,这样的话相当于每个窗口都会卖出10张票,每一个TicketWindow对象都有10张票,这显然是不合理的,因为我们的要求是共有10张票,而不是每个窗口有10张票,这10张票应该被4个购票窗口共享。因此我们需要使用Runnable接口,就可以实现资源共享。如下面的代码

public class TicketRunnableTest {
    public static void main(String[] args) {
        //匿名内部类
        Runnable tw = new Runnable() {
            int tickets = 10;//共10张票
            @Override
            public void run() {
                while(tickets>0) {
                    System.out.printf("%s窗口第%d张票已经售出\n", Thread.currentThread().getName(), tickets--);
                }
            }
        };
//        //也可以写Lambda表达式
//        Runnable tw = ()->{
//            int tickets = 10;//共10张票
//                while(tickets>0) {
//                    System.out.printf("%s窗口第%d张票已经售出\n", Thread.currentThread().getName(), tickets--);
//                }
//            };
//新建Thread对象,都使用tw接口,这样就实现了共享
        new Thread(tw, "窗口1").start();
        new Thread(tw, "窗口2").start();
        new Thread(tw, "窗口3").start();
        new Thread(tw, "窗口4").start();
    }
}
//输出结果:
//窗口1窗口第10张票已经售出
//窗口4窗口第7张票已经售出
//窗口4窗口第5张票已经售出
//窗口4窗口第4张票已经售出
//窗口4窗口第3张票已经售出
//窗口4窗口第2张票已经售出
//窗口4窗口第1张票已经售出
//窗口3窗口第8张票已经售出
//窗口2窗口第9张票已经售出
//窗口1窗口第6张票已经售出

可以看到用Runnable实现多线程时,我们只需要在调用Thread方法时,传入相同的接口,这样就能共享同一个余票数据。

五、Runnable接口和Thread类的关系

1.通过实现Runnable接口,使得该类有了多线程类的特征。run()方法是多线程程序的一个执行目标(target)。所有的多线程代码都在run方法里面。Thread类实际上也是实现了Runnable接口的类。

2.在启动的多线程的时候,需要先通过Thread类的构造方法Thread(Runnable target) 构造出对象,然后调用Thread对象的start()方法来运行多线程代码。

3.实际上所有的多线程代码都是通过运行Thread的start()方法来运行的。因此,不管是继承Thread类还是实现Runnable接口来实现多线程,最终还是通过Thread的对象的API来控制线程的,熟悉Thread类的API是进行多线程编程的基础。

第4课 Callable接口、后台线程

一、Callable接口介绍

Callable接口是实现多线程的第三种方式,与Runnable接口实现多线程的方式基本相同,主要区别是Callable接口中的方法有返回值,并且可以声明抛出异常,满足了既能创建多线程又可以有返回值的需求。

1.java.util.concurrent.Callable是一个泛型接口,只有一个call()方法,因此也是一个函数式接口。

2.call()方法抛出异常Exception异常,且返回一个指定的泛型类对象。

二、Callable接口实现多线程

1.Callable接口实现多线程是通过Thread类的有参构造方法传入Runnable接口类型的参数来实现多线程,不同的是,这里传入的是Runnable接口的子类FutureTask对象作为参数,而FutureTask对象中则封装带有返回值的Callable接口实现类。

2.FutureTask继承关系图

说明:

  • Callable接口方式实现的多线程是通过FutureTask类来封装和管理返回结果的,该类的直接父接口是RunnableFuture。
  • FutureTask本质是Runnable接口和Future接口的实现类,而Future接口则是JDK 5提供的用来管理线程执行返回结果的。

3.future接口常用方法

4.实现步骤

  • 创建一个Callable接口的实现类
  • 在实现类中重写Callable接口的call方法,设置线程任务
  • 创建一个Callable接口的实现类对象
  • 创建FutureTask类对象有参构造方法传入Callable接口,泛型与Callable接口的泛型一致。
  • 创建Thread类对象,有参构造方法中传递FutureTask对象
  • 调用Thread类中的start方法,开启新的线程执行run方法

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
//1.创建一个Callable接口的实现类
class MyCallableThread implements Callable {
    int sum = 0;
//2.在实现类中重写Callable接口的call方法,设置线程任务
    @Override
    public Object call() throws Exception {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + "当前数值:" + i);
            sum += i;
        }
        return sum;
    }
}

public class CallableTest {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        //线程1
        FutureTask<Integer> futureTask1 = new FutureTask<>(new MyCallableThread());
        Thread thread1 = new Thread(futureTask1, "线程1");
        thread1.start();
        //线程2
        FutureTask<Integer> futureTask2 = new FutureTask<>(new MyCallableThread());
        Thread thread2 = new Thread(futureTask2, "线程2");
        thread2.start();
//调用future接口的get方法获取返回值
        System.out.println("线程1返回值:" + futureTask1.get());
        System.out.println("线程2返回值:" + futureTask2.get());
    }
}
//输出结果:
//线程2当前数值:0
//线程1当前数值:0
//线程2当前数值:1
//线程1当前数值:1
//线程2当前数值:2
//线程1当前数值:2
//线程1当前数值:3
//线程1当前数值:4
//线程2当前数值:3
//线程2当前数值:4
//线程1返回值:10
//线程2返回值:10

三、后台线程

1.前台线程与后台线程

  • 在Java程序中,只要还有一个前台进程在运行,整个进程就不会结束,如果一个进程中只有后台线程在运行,这个进程就会结束。
  • 新创建的线程默认是前台线程,如果需要将其设置为后台线程,要在该线程调用start方法启动之前,调用setDaemon(true)语句,这个线程就会变成后台线程。
  • 前台线程也叫用户线程,后台线程也叫守护线程。

2.相关方法(Thread类)

  • void setDaemon(boolean on):将该线程设置为后台线程(守护线程)或前台线程(用户线程)。 true为后台,false为前台。
  • boolean isDaemon():判断该线程是否为后台线程。

3.当我们需要在操作A完成之前,持续执行操作B时,可以将操作B设置为后台线程,这样操作A执行完成时,由于程序没有前台线程了,于是整个进程就会结束,操作B也会随即停止。

例如以下代码,我们将thread1设置成后台线程,run方法为打印100次"后台线程(守护线程)运行中",thread2对象中,run方法为打印5次"前台线程(用户线程)运行中"。

public class BackgroundThreadTest {
    public static void main(String[] args) {
        Thread thread1 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    System.out.println("后台线程(守护线程)运行中");
                }
            }
        };
        thread1.setDaemon(true);//将线程1设置为后台线程

        Thread thread2 = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 5; i++) {
                    System.out.println("前台线程(用户线程)运行中");
                }
            }
        };
        thread1.start();//启动后台线程
        thread2.start();//启动前台线程
    }
}
//输出结果:
//后台线程(守护线程)运行中
//后台线程(守护线程)运行中
//后台线程(守护线程)运行中
//后台线程(守护线程)运行中
//后台线程(守护线程)运行中
//前台线程(用户线程)运行中
//前台线程(用户线程)运行中
//前台线程(用户线程)运行中
//前台线程(用户线程)运行中
//前台线程(用户线程)运行中
//后台线程(守护线程)运行中
//后台线程(守护线程)运行中
//后台线程(守护线程)运行中
//后台线程(守护线程)运行中
//后台线程(守护线程)运行中
//后台线程(守护线程)运行中
//后台线程(守护线程)运行中
//后台线程(守护线程)运行中

可以看到,输出结果并没有打印到100次 "后台线程(守护线程)运行中" ,就停止了。这是因为,前台线程thread2打印5次"前台线程(用户线程)运行中"后,整个进程就会结束 ,此时JVM通知后台线程结束,由于后台线程从接收通知到做出响应,需要一定的时间,因此又打印了几次"后台线程(守护线程)运行中"后,后台进程也终止了。

第5课 线程安全、多线程同步

一、线程安全

1.线程安全:如果有多个线程在同时运行,而这些线程可能会同时运行这段代码。程序每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的。

2.以售票为例,我们以线程不安全的情形模拟电影院的卖票过程

假设有一个电影,某个场次只售100张票,我们只有1个售票窗口,那么这就相当于单线程,是不存在线程安全问题的;如果有4个窗口一起卖票,但是卖的票不同,1号窗口卖1-26号票,2号窗口卖26-50号票,3号窗口卖51-75号票,4号窗口卖76-100号票,因为4个窗口并不共享数据,因此这也是不会产生线程安全问题的;但是,如果4个窗口卖的票是一样的,都卖的是1-100号票,就会出现线程安全问题,因为多线程访问了共享的数据,就会产生线程安全问题。

public class TicketSellTest {
    public static void main(String[] args) {
        //匿名内部类
        Runnable tw = new Runnable() {
            //定义一个多个线程共享的票源
            private int tickets = 100;
            //设置线程任务:卖票
            @Override
            public void run() {
                //使用死循环,让卖票操作重复执行
                while(true){
                    if(tickets>0) {
                        //假设卖一张票要线程休眠100毫秒
                        try {
                            Thread.sleep(100);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        System.out.printf("%s窗口第%d张票已经售出\n", Thread.currentThread().getName(), tickets--);
                    }
                }
            }
        };
        //4个窗口一起卖票
        new Thread(tw, "窗口1").start();
        new Thread(tw, "窗口2").start();
        new Thread(tw, "窗口3").start();
        new Thread(tw, "窗口4").start();
    }
}
//输出结果最后5行:
//窗口2窗口第2张票已经售出
//窗口4窗口第1张票已经售出
//窗口1窗口第1张票已经售出
//窗口3窗口第0张票已经售出
//窗口2窗口第-1张票已经售出

从输出结果可以发现两个问题:

  • 卖出了相同的票,比如第1张票被卖了两回。
  • 卖出了不存在的票,比如0票与-1票。

出现这样的问题,称为线程不安全,因为几个窗口(线程)票数不同步了。

3.线程安全问题的产生的原因和解决

(1)产生原因:线程安全问题都是由全局变量及静态变量引起的。若每个线程中对全局变量、静态变量只有读操作,而无写操作,一般来说,这个全局变量是线程安全的;若有多个线程同时执行写操作,一般都需要考虑线程同步, 否则的话就可能影响线程安全

(2)如何解决:可以让一个线程在访问共享数据的时候无论是否失去了cpu的执行权,都让其他的线程只能等待,等待当前线程卖完票之后,其他线程再继续卖票。总而言之,同一时刻要保证只有一个线程在卖票。

二、多线程同步

当我们使用多个线程访问同一资源的时候,且多个线程中对资源有写的操作,就容易出现线程安全问题。 要解决多线程并发访问一个资源的安全性问题:也就是解决重复票与不存在票问题,Java中提供了同步机制 (synchronized)来解决。

1.多线程同步概述

还是以卖票为例,窗口1线程进入操作的时候,窗口2和窗口3线程只能在外等着,窗口1操作结束,窗口1和窗口2和窗口3才有机会进入代码去执行。也就是说在某个线程修改共享资源的时候,其他线程不能修改该资源,等待修改完毕同步之后,才能去抢夺CPU 资源,完成对应的操作,保证了数据的同步性,解决了线程不安全的现象。 为了保证每个线程都能正常执行原子操作,Java引入了线程同步机制

2.实现多线程同步有三种方式

  • 同步代码块
  • 同步方法
  • Lock锁

三、多线程同步的实现

  • 方案一:同步代码块

1.同步代码块

(1)synchronized关键字可以用于方法中的某个区块中,表示只对这个区块的资源实行互斥访问,这段代码块被称作同步代码快。

synchronized(锁对象) {
    //可能会出现线程安全问题的代码(访问了共享数据的代码)
}

上述代码中,锁对象是一个任意类型的对象,可以想象为在对象上标记了一个锁,它是同步代码块的关键。并且有以下注意事项:

  • 锁对象可以是任意类型。
  • 多个线程对象要使用同一把锁。
  • 锁对象作用:把同步代码块锁住,只让一个线程在同步代码块中执行。
  • 在任何时候,最多允许一个线程拥有同步锁,谁拿到锁就进入代码块,其他的线程只能在外等着

2.同步代码块原理

当线程执行同步代码块时,首先会检查锁对象的标志位,默认情况下标志位为1,此时线程会执行同步代码块,同时将锁对象的标志位置为0。可以理解为1为可进入,0为不可进入。

当一个新的线程执行到这段同步代码块时,如果锁对象的标志位为0,新线程会发生阻塞,等待当前线程执行完同步代码块后,锁对象的标志位被置为1,新线程才能进人同步代码块执行其中的代码。这样循环往复,直到共享资源被处理完为止。

以上过程就好比一个公用电话亭,只有前一个人打完电话出来后,后面的人才可以进去打。

3.同步代码块实现多线程同步

还是以卖票为例,我们把访问了共享数据(余票)的代码放在synchronized代码块中,就不会出现卖出重复票或者卖出不存在的票的问题了。

注意事项:锁对象的创建代码,不能放在run方法内部,否则每个线程运行到run方法,都会创建一个新的对象,这样每个线程都会有一个不同的锁,违背了多个线程对象要使用同一把锁的规则,线程之间不能产生同步效果。

public class SafeSellTicketBySynchronized {
    public static void main(String[] args) {
        //匿名内部类
        Runnable tw = new Runnable() {
            //定义一个多个线程共享的票源
            private int tickets = 10000;
            //创建一个任意的对象,作为锁对象
            Object lock = new Object();
            //设置线程任务:卖票
            @Override
            public void run() {
                //使用死循环,让卖票操作重复执行
                while(true){
                    //同步代码块
                    synchronized (lock){
                        if (tickets > 0) {
                            //假设卖一张票要线程休眠10毫秒
                            try {
                                Thread.sleep(10);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
                            System.out.printf("%s窗口第%d张票已经售出\n", Thread.currentThread().getName(), tickets--);
                        }
                    }
                }
            }
        };
        //4个窗口一起卖票
        new Thread(tw, "窗口1").start();
        new Thread(tw, "窗口2").start();
        new Thread(tw, "窗口3").start();
        new Thread(tw, "窗口4").start();
    }
}
//部分输出结果:
//窗口1窗口第8480张票已经售出
//窗口1窗口第8479张票已经售出
//窗口1窗口第8478张票已经售出
//窗口4窗口第8477张票已经售出
//窗口4窗口第8476张票已经售出
//窗口4窗口第8475张票已经售出
//...
//窗口1窗口第1张票已经售出

TIPS:由于作者的CPU性能可能比较高,直到我把总票数设计为10000张,输出结果才终于出现了其他的窗口卖票。

可以从输出结果看到,售出的票不再出现重复票,或者0和负数的情况,线程安全问题得到解决。

  • 方案二:同步方法

1.同步方法

(1)使用synchronized修饰的方法,就叫做同步方法。这样的方法在某一时刻只允许一个线程执行,其他线程只能在方法外等着,当前线程执行完毕,其他线程才有机会执行。格式如下:

修饰符 synchronized 返回值类型 方法名(参数列表){
    //可能会产生线程安全问题的代码(访问了共享数据的代码)
}

(2)同步方法的同步锁

  • 非静态方法:同步锁是this指向的对象。这保证了方法所在的对象对于所有线程来说是唯一的,保证了所有线程使用同一把锁。
  • 静态方法:同步锁是方法所在类的字节码对象(类名.class)。

2.同步方法实现多线程同步

仍然以卖票为例,我们单独定义一个卖票的方法,将该方法用synchronized关键字修饰,然后在run方法中调用该方法,可以看到这样不会出现卖出重复票或者卖出不存在的票。

public class SafeSellTicketBySynchronizedMethodTest {
    public static void main(String[] args) {
        //匿名内部类
        Runnable tw = new Runnable() {
            //定义一个多个线程共享的票源
            private int tickets = 100;
            //设置线程任务:卖票
            @Override
            public void run() {
                //使用死循环,让卖票操作重复执行
                while (true) {
                    //访问了共享数据的代码
                    sellTicket();
                }
            }
            //同步方法(非静态)
            public synchronized void sellTicket(){
                if (tickets > 0) {
                    //假设卖一张票要线程休眠10毫秒
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.printf("%s窗口第%d张票已经售出\n", Thread.currentThread().getName(), tickets--);
                }
            }
        };
        //4个窗口一起卖票
        new Thread(tw, "窗口1").start();
        new Thread(tw, "窗口2").start();
        new Thread(tw, "窗口3").start();
        new Thread(tw, "窗口4").start();
    }
}
//部分输出结果:
//窗口1窗口第51张票已经售出
//窗口1窗口第50张票已经售出
//窗口1窗口第49张票已经售出
//窗口4窗口第48张票已经售出
//窗口4窗口第47张票已经售出
//窗口4窗口第46张票已经售出
//...
//窗口4窗口第1张票已经售出

  • 方案三:Lock锁(重点)

同步代码块和同步方法解决多线程虽然解决了多线程问题,但是他也有一些限制,例如线程在执行同步代码时每次都要判断锁对象的状态,非常消耗资源,效率较低;无法中断一个正在等待获得锁的线程;也无法通过轮询得到锁……

1.java.util.concurrent.locks.Lock接口被称为Lock锁,它提供了比synchronized代码块synchronized方法更广泛的锁定操作,同步代码块/同步方法具有的功能Lock锁都有,除此之外它更强大,更灵活,更体现面向对象。

2.Lock锁最大的优势:让某个线程在等待获取同步锁失败后返回,不再继续等待。

3.Lock锁的常用方法

  • void lock():获取锁
  • void unlock():释放锁
  • boolean tryLock():判断某个线程锁是否可用

4.Lock锁实现多线程同步

(1)使用步骤

  • 在成员位置通过Lock接口实现类ReentrantLock创建一个Lock锁对象
  • 在可能会出现安全问题的代码前调用Lock接口中的lock方法获取锁
  • 在可能会出现安全问题的代码后调用Lock接口中的unlock方法释放锁

注意:通常情况下在try-finally语句finally代码块调用unlock()方法来释放锁。

(2)卖票案例

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class SafeSellTicketByLockTest {
    public static void main(String[] args) {
        //匿名内部类
        Runnable tw = new Runnable() {
            //定义一个多个线程共享的票源
            private int tickets = 100;
            //在成员位置通过Lock接口实现类ReentrantLock创建一个Lock锁对象
            Lock lock = new ReentrantLock();

            //设置线程任务:卖票
            @Override
            public void run() {
                //使用死循环,让卖票操作重复执行
                while (true) {
                    //在可能会出现安全问题的代码前调用lock方法获取锁
                    lock.lock();
                    if (tickets > 0) {
                        //使用finally代码块保证锁的释放
                        try {
                            Thread.sleep(10);
                            System.out.printf("%s窗口第%d张票已经售出\n", Thread.currentThread().getName(), tickets--);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        } finally {
                            //在可能会出现安全问题的代码后调用unlock方法释放锁
                            lock.unlock();
                        }
                    }
                }
            }
        };
        //4个窗口一起卖票
        new Thread(tw, "窗口1").start();
        new Thread(tw, "窗口2").start();
        new Thread(tw, "窗口3").start();
        new Thread(tw, "窗口4").start();
    }
}
//部分输出结果:
//窗口1窗口第17张票已经售出
//窗口1窗口第16张票已经售出
//窗口1窗口第15张票已经售出
//窗口2窗口第14张票已经售出
//窗口2窗口第13张票已经售出
//窗口2窗口第12张票已经售出
//...
//窗口2窗口第1张票已经售出

以上代码同样实现了安全售票,可见使用lock方法和unlock方法进行加锁和释放锁更加灵活。

第6课 线程状态

一、线程状态概述

在Java中,任何对象都有生命周期,线程也不例外,它也有自己的生命周期。当Thread对象创建完成时,线程的生命周期便开始了。当线程任务中代码正常执行完毕或者线程抛出一个未捕获的异常(Exception)或者错误(Error) 时,线程的生命周期便会结束。

当线程被创建并启动以后,它既不是一启动就进入了执行状态,也不是一直处于执行状态。在线程的生命周期中,共有6种状态,在API中java.lang.Thread.State这个枚举中给出了六种线程状态。

这里先列出各个线程状态发生的条件,下面将会对每种状态进行详细解析。

线程状态 导致状态发生条件
NEW(新建) 线程刚被创建,但是并未启动。还没调用start方法。
RUNNABLE(可运行) 线程可以在JVM中运行的状态,可能正在运行自己代码,也可能没有,这取决于操作系统处理器。
BLOCKED(锁阻塞) 当一个线程试图获取一个对象锁,而该对象锁被其他的线程持有,则该线程进入Blocked状态;当该线程持有锁时,该线程将变成Runnable状态。
WAITING(无限等待) 一个线程在等待另一个线程执行一个(唤醒)动作时,该线程进入Waiting状态。进入这个状态后是不能自动唤醒的,必须等待另一个线程调用notify或者notifyAll方法才能够唤醒。
TIMED_WAITING(计时等待) 同Waiting状态,有几个方法有超时参数,调用他们将进入Timed Waiting状态。这一状态将一直保持到超时期满或者接收到唤醒通知。带有超时参数的常用方法有Thread.sleepObject.wait
TERMINATED(被终止) 因为run方法正常退出而死亡,或者因为没有捕获的异常终止了run方法而死亡。

二、线程状态详解

1.NEW(新建)

创建一个线程对象后,该线程对象就处于新建状态,此时它不能运行,和其他Java对象一样,仅仅由JVM为其分配了内存,没有表现出任何线程的动态特征。

2.RUNNABLE(可运行)

新建状态的线程调用start()方法,就会进入可运行状态。

在RUNNABLE状态内部又可细分成两种状态:READY(就绪状态)RUNNING(运行状态),并且线程可以在这两个状态之间相互转换。

  • 就绪状态:线程对象调用start()方法之后,等待JVM的调度,此时线程并没有运行。
  • 运行状态:线程对象获得JVM调度,如果存在多个CPU(多核心),那么允许多个线程并行运行。

3.TIMED_WAITING(计时等待)

(1)运行状态中的线程调用了有时间参数限制的方法,如sleep(long millis)、wait(long timeout)、join(long millis)等方法,就会转换为定时等待状态

(2)定时等待状态中的线程不能立即争夺CPU使用权,必须等待其他相关线程执行完特定的操作或者限时时间结束后,才有机会再次争夺CPU使用权。

(3)进入到TimeWaiting(计时等待)状态有两种方式

  • 使用sleep(long m)方法,在毫秒值结束之后,线程睡醒进入到Runnable/Blocked状态。
  • 使用wait(long m)方法,wait方法如果在毫秒值结束之后,还没有被notify方法唤醒,就会自动醒来,线程睡醒进入到Runnable/Blocked状态。

(4) 例如,在我们写卖票的案例中,为了减少线程执行太快,现象不明显等问题,我们在run方法中添加了sleep语句,这样就强制当前正在执行的线程休眠(暂停执行),以“减慢线程”。

当我们调用了sleep方法之后,当前执行的线程就进入到“休眠状态”,其实就是所谓的Timed Waiting(计时等待),需要通过其他线程调用notify()或者notifyAl()方法唤醒当前等待中的线程,或者等待限时时间结束后线程自动唤醒。

如下面这个例子, 实现一个计数器,计数到100,在每个数字之间暂停1秒,每隔10个数字输出一个字符串。

public class TimeWaitingCountTest {
    public static void main(String[] args) {
        new Thread(){
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    if (i % 10 == 0) {
                        System.out.println("‐‐‐‐‐‐‐" + i);
                    }
                    System.out.print(i);
                    try {
                        Thread.sleep(1000);
                        System.out.print(" 线程睡眠1秒!\n");
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }.start();
    }
}
//部分输出结果:
//‐‐‐‐‐‐‐0
//0 线程睡眠1秒!
//1 线程睡眠1秒!
//2 线程睡眠1秒!
//3 线程睡眠1秒!
//4 线程睡眠1秒!
//5 线程睡眠1秒!
//6 线程睡眠1秒!
//7 线程睡眠1秒!
//8 线程睡眠1秒!
//9 线程睡眠1秒!
//‐‐‐‐‐‐‐10
//10 线程睡眠1秒!
//11 线程睡眠1秒!
//12 线程睡眠1秒!
//13 线程睡眠1秒!

以上代码使用了sleep方法,有以下注意实现

  • 进入TIMED_WAITING状态的一种常见情形是调用的sleep方法单独的线程也可以调用,不一定非要有协作关系。
  • 为了让其他线程有机会执行,可以将Thread.sleep()的调用放线程run()之内,这样才能保证该线程执行过程中会睡眠。
  • sleep与锁无关,线程睡眠到期自动苏醒,并返回到Runnable(可运行)状态。
  • sleep(long millis)方法中指定的时间是线程不会运行的最短时间。因此,sleep()方法不能保证该线程睡眠到期后就开始立刻执行。

4.BLOCKED(锁阻塞)

(1)在上一课已经介绍了多线程同步的机制,因此这个状态是很好理解的了。比如,线程A与线程B代码中使用同一个锁对象,如果线程A获取到锁,线程A进入到Runnable状态,那么线程B就进入到Blocked锁阻塞状态, 这是由Runnable状态进入Blocked状态

(2)除此之外, Waiting和Time Waiting状态也会在某种情况下进入阻塞状态,当线程等待结束时(唤醒),CPU不空闲,该线程没有抢夺到CPU执行权,则也会进入BLOCKED状态。

(3)同时,线程运行过程中,发出I/O请求时,该线程也会进入BLOCKED状态。

5.WAITING(无限等待)

(1)运行状态的线程调用了无时间参数限制的方法后,如wait()、 join()等方法, 就会转换为等待状态。

(2)等待状态中的线程不能立即争夺CPU使用权,必须等待其他线程执行特定的操作后,才有机会争夺CPU使用权。

(3)例如,调用wait()方法而处于等待状态中的线程,必须等待其他线程调用notify()或者notifyAll()方法唤醒当前等待中的线程;调用join()方法而处于等待状态中的线程,必须等待其他加入的线程终止

TIPS:WAITING状态体现的是多个线程间的通信,可以理解为多个线程之间的协作关系,可以看第三部分的“WAITING案例”。

6.TERMINATED(被终止)

(1)线程的run()方法、cal()方法正常执行完毕或者线程抛出一一个未捕获的异常(Exception)、错误(Error) ,线程就进入终止状态。

(2)一旦进入终止状态,线程将不再拥有运行的资格,也不能再转换到其他状态,生命周期结束。

7.线程状态总结

三、 WAITING案例

此案例是对WAITING状态的一个应用,体现的是多个线程间的通信。假设有一个包子铺,顾客要买包子,老板要现场做包子,顾客需要等待老板把包子做好。

实现这个案例,就需要线程之间进行通信。

1.案例分析

  • 顾客线程(消费者):告知老板要的包子的种类和数量,调用wait方法,放弃cpu的执行,进入到WAITING状态(无限等待)
  • 老板线程(生产者):花了5秒做包子,做好包子之后,调用notify方法,唤醒顾客吃包子

注意事项:

  • 顾客和老板线程必须使用同步代码块包裹起来,保证等待和唤醒只能有一个在执行
  • 同步使用的锁对象必须保证唯一
  • 只有锁对象才能调用wait方法和notify方法

2.相关方法

Obejct类中的方法:

void wait():在其他线程调用此对象的notify()方法或notifyAll()方法前,导致当前线程等待。

void notify():唤醒在此对象监视器上等待的单个线程,唤醒后会继续执行wait方法之后的代码。

3.代码实现

import java.time.LocalDateTime;
public class WaitingWakeTest {
    public static void main(String[] args) {
        //创建锁对象
        Object lock = new Object();
        //创建一个顾客线程(消费者)
        new Thread() {
            @Override
            public void run() {
                //使用同步代码块保证只有等待和唤醒的线程只有一个在执行
                synchronized (lock) {
                    System.out.println(LocalDateTime.now());
                    System.out.println("顾客:老板来个芽菜包子");
                    //调用wait方法,放弃cpu的执行,进入到WAITING状态,等待包子做好
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    //唤醒之后执行的代码
                    System.out.println("顾客:收到包子,真香");
                }
            }
        }.start();

        //创建一个老板线程(生产者)
        new Thread(){
            @Override
            public void run() {
                //使用同步代码块保证只有等待和唤醒的线程只有一个在执行
                synchronized (lock){
                    try {
                        System.out.println("老板:要得,5秒钟做好");
                        Thread.sleep(5000);//花5秒做包子
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    //通知包子做好了,唤醒顾客线程
                    System.out.println(LocalDateTime.now());
                    System.out.println("老板:包子拿好");
                    lock.notify();
                }
            }
        }.start();
    }
}
//输出结果:
//2020-06-03T23:38:32.015
//顾客:老板来个芽菜包子
//老板:要得,5秒钟做好
//2020-06-03T23:38:37.016
//老板:包子拿好
//顾客:收到包子,真香

通过这个案例可以看到, 一个调用了某对象的wait方法的线程会等待另一个线程调用此对象的notify()方法或notifyAll()方法。

说明Waiting状态并不是一个线程的操作,它体现的是多个线程间的通信,可以理解为多个线程之间的协作关系,多个线程会争取锁,同时相互之间又存在协作关系。

当多个线程协作时,比如A,B线程,如果A线程在Runnable(可运行)状态中调用了wait()方法那么A线程就进入了Waiting(无限等待)状态,同时失去了同步锁。假如这个时候B线程获取到了同步锁,在运行状态中调用了notify()方法,那么就会将无限等待的A线程唤醒。注意是唤醒,如果获取到锁对象,那么A线程唤醒后就进入Runnable(可运行)状态;如果没有获取锁对象,那么就进入到Blocked(锁阻塞状态)。

第7课 线程间通信、等待唤醒机制

在多线程的程序中,上下工序可以看作两个线程,如果这两个线程之间需要协同完成工作,就需要线程之间进行通信。

一、线程间通信

1.概念:多个线程在处理同一个资源,但是处理的动作(线程的任务)却不相同。

比如:线程A用来做包子的,线程B用来吃包子的,包子可以理解为同一资源,线程A与线程B处理的动作,一个是生产,一个是消费,那么线程A与线程B之间就存在线程通信问题。

2. 为什么要处理线程间通信

多个线程并发执行时,在默认情况下CPU是随机切换线程的(谁抢到CPU执行权谁执行),当我们需要多个线程来共同完成一件任务,并且我们希望他们有规律的执行,那么多线程之间需要一些协调通信,以此来帮我们达到多线程共同操作一份数据。

3. 如何保证线程间通信有效利用资源

多个线程在处理同一个资源,并且任务不同时,需要线程通信来帮助解决线程之间对同一个变量的使用或操作。 就是多个线程在操作同一份数据时, 避免对同一共享变量的争夺。也就是我们需要通过一定的手段使各个线程能有效的利用资源。而这种手段即——等待唤醒机制。

二、等待唤醒机制

1.什么是等待唤醒机制

在一个线程进行了规定操作后,就进入等待状态(wait()方法),等待其他线程执行完他们的指定代码过后再将其唤醒(notify()方法);在有多个线程进行等待时,如果需要,可以使用notifyAll()方法来唤醒所有的等待线程。

wait/notify 就是线程间的一种协作机制。

2.等待唤醒中的方法

(1)相关方法

wait set:所有的对象都会有一个wait set,用来存放调用了该对象的wait方法之后进入block状态的线程

  • void wait():线程不再活动,不再参与调度,进入wait set中,因此不会浪费 CPU 资源,也不会去竞争锁了,这时的线程状态即是 WAITING。它还要等着别的线程执行一个特别的动作——“通知(notify)”之后,在这个对象上等待的线程才会从wait set中释放出来,重新进入到调度队列(ready queue)中。
  • void notify(): 唤醒此同步锁上的wait set中的第一个线程。例如,餐馆有空位置后,等候就餐最久的顾客最先入座。
  • void notifyAll():唤醒此同步锁上的wait set中所有线程

(2)注意事项

哪怕只通知了一个等待的线程,被通知线程也不能立即恢复执行,因为它当初中断的地方是在同步块内,而此刻它已经不持有锁,所以她需要再次尝试去获取锁(很可能面临其它线程的竞争),成功后才能在当初调用wait方法之后的地方恢复执行。

即:如果能获取锁,线程就从 WAITING 状态变成 RUNNABLE 状态;否则,从wait set出来,又进入entry set,线程就从WAITING状态又变成BLOCKED状态。

3.调用wait方法和notify方法需要注意的细节

  • wait方法与notify方法必须要由同一个锁对象调用。因为:对应的锁对象可以通过notify唤醒使用同一个锁对象调用的wait方法后的线程。
  • wait方法与notify方法是属于Object类的方法的。因为:锁对象可以是任意对象,而任意对象的所属类都是继承了Object类的。
  • wait方法与notify方法必须要在同步代码块或者是同步函数中使用。因为:必须要通过锁对象调用这2个方法。

三、生产者与消费者问题

等待唤醒机制其实就是经典的“生产者与消费者”的问题。还是以生产包子和消费包子为例,这一次我们考虑更多细节。

1.案例分析

(1)有效的利用资源:生产一个包子,吃一个包子,再生产一个包子,在吃一 个包子...

不要出现包子没有生产出来,就已经开吃了的情况。

(2)通信:对包子的状态进行判断

没有包子-->吃货线程唤醒包子铺线程-->吃货线程等待-->包子铺线程做包子-->做好包子-->修改包子的状态为有

有包子-->包子铺线程提醒吃货线程-->包子铺线程等待-->吃货吃包子-->吃完包子-->修改包子的状态为没有

如此往复

(3)需要哪些类

  • 资源类:包子类

设置包子的属性:皮、馅儿、包子的状态:有(true)、无(false)

  • 生产者类:包子铺类,是一个线程类,可以继承Thread类

设置线程任务(run):生产包子

对包子状态进行判断true 有包子,包子铺调用wait方法进入等待状态。

flase 没有包子,包子铺生产包子。可以增加一些趣味性,比如交替生产两种包子。包子铺做好了包子,修改包子的状态为true,唤醒吃货线程,让吃货线程吃包子。

  • 消费者类:吃货类,也是一个线程类,可以继承Thread类

设置线程任务(run):吃包子

对包子状态进行判断false 没有包子,吃货线程调用wait方法进入等待状态。

true 吃包子,吃货吃包子,吃完后修改包子的状态为false,然后唤醒包子铺线程生产包子。

  • 测试类(main方法所在的类):创建包子对象,创建包子铺线程和吃货线程并启动线程。

(4)注意事项

  • 包子铺线程和包子线程关系是通信(互斥)关系,必须使用多线程同步技术保证两个线程只能有一个在执行。
  • 锁对象必须保证唯一,可以使用包子对象作为锁对象
  • 包子铺线程和吃货线程就需要把包子对象作为参数传递进来
  • 1.需要在包子类成员位置创建一个包子变量
  • 2.使用带参数构造方法,为这个包子变量赋值

2.代码实现

import java.time.LocalDateTime;

//包子类
class Bun {
    String pi;//皮
    String xian;//馅儿
    boolean haveBun = false;//包子的状态,默认为没有包子(false)
}

//包子铺类
class BunShop extends Thread {
    //在成员变量位置创建一个包子变量,作为锁对象
    private Bun baozi;
    int count = 0;

    //使用带参数的构造方法为这个包子赋值
    public BunShop(Bun baozi) {
        this.baozi = baozi;
    }

    //设置线程任务:生产包子
    @Override
    public void run() {
        //让包子铺一直生产包子
        while (true) {
            //保证两个线程只有一个在执行
            synchronized (baozi) {
                //判断有没有包子
                if (true == baozi.haveBun) {
                    //有包子,调用wait方法进入等待状态
                    try {
                        baozi.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }

                //被唤醒之后,包子铺生产包子
                //这里增加一些趣味性,交替生产两种包子
                if (0 == count % 2) {
                    //生产薄皮三鲜馅包子
                    baozi.pi = "薄皮";
                    baozi.xian = "三鲜馅";
                } else {
                    //生产冰皮蘑菇粉丝馅
                    baozi.pi = "冰皮";
                    baozi.xian = "蘑菇粉丝馅";
                }
                //开始做包子
                System.out.println("包子铺:正在做" + baozi.pi + baozi.xian + "包子,预计等待5秒钟");
                System.out.println(LocalDateTime.now());
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //做好包子
                baozi.haveBun = true;
                //唤醒吃货线程,让他吃包子
                System.out.println("包子铺:" + baozi.pi + baozi.xian + "包子已做好,请拿好");
                System.out.println(LocalDateTime.now());
                baozi.notify();//notify方法
            }
        }
    }
}

class ChiHuo extends Thread {
    //在成员变量位置创建一个包子变量,作为锁对象
    private Bun baozi;
    int count = 0;

    //使用带参数的构造方法为这个包子赋值
    public ChiHuo(Bun baozi) {
        this.baozi = baozi;
    }

    //设置线程任务:吃包子

    @Override
    public void run() {
        //让吃货一直吃包子
        while (true) {
            synchronized (baozi) {
                //判断有没有包子
                if (false == baozi.haveBun) {
                    //没有包子就等待
                    try {
                        baozi.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                //被唤醒之后,吃货开始吃包子
                System.out.println("吃货:收到" + baozi.pi + baozi.xian + "包子,看我3秒吃掉");
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //吃货吃完包子,修改包子状态
                baozi.haveBun = false;
                System.out.println("吃货:吃完了"+baozi.pi + baozi.xian+"包子,真香,给我5秒钟喝口水再来一个");
                System.out.println(LocalDateTime.now());
                //等待5秒唤醒包子铺线程开始做包子
                try {
                    Thread.sleep(5000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                baozi.notify();//唤醒
                System.out.println("------------------------");
            }
        }
    }
}

public class BunShopTest {
    public static void main(String[] args) {
        //创建包子对象
        Bun baozi = new Bun();
        //创建包子铺线程,传入包子,然后启动线程
        new BunShop(baozi).start();
        //创建吃货线程,传入包子,然后启动线程
        new ChiHuo(baozi).start();
    }
}
//输出结果:
//包子铺:正在做薄皮三鲜馅包子,预计等待5秒钟
//2020-06-04T10:38:31.944
//包子铺:薄皮三鲜馅包子已做好,请拿好
//2020-06-04T10:38:36.945
//吃货:收到薄皮三鲜馅包子,看我3秒吃掉
//吃货:吃完了薄皮三鲜馅包子,真香,给我5秒钟喝口水再来一个
//2020-06-04T10:38:39.945
//------------------------
//包子铺:正在做薄皮三鲜馅包子,预计等待5秒钟
//2020-06-04T10:38:44.946
//包子铺:薄皮三鲜馅包子已做好,请拿好
//2020-06-04T10:38:49.946
//吃货:收到薄皮三鲜馅包子,看我3秒吃掉
//吃货:吃完了薄皮三鲜馅包子,真香,给我5秒钟喝口水再来一个
//2020-06-04T10:38:52.946
//------------------------
//包子铺:正在做薄皮三鲜馅包子,预计等待5秒钟
//2020-06-04T10:38:57.946
//包子铺:薄皮三鲜馅包子已做好,请拿好
//2020-06-04T10:39:02.947
//吃货:收到薄皮三鲜馅包子,看我3秒吃掉
//吃货:吃完了薄皮三鲜馅包子,真香,给我5秒钟喝口水再来一个
//2020-06-04T10:39:05.947

第8课 抢占式调度模型

在第1课中已经介绍了线程调度的相关概念,Java程序采用的是抢占式调度模型,本课内容主要对抢占式调度模型进行介绍。

抢占式调度模型让可运行池中所有就绪状态的线程争抢CPU的使用权,而优先级高的线程获取CPU执行权的概率大于优先级低的线程。

一、线程的优先级

1.在应用程序中,要对线程进行调度,最直接的方式就是设置线程的优先级。优先级越高的线程获得CPU执行的机会越大,而优先级越低的线程获得CPU执行的机会越小。

2.线程的优先级用1~10之间的整数来表示,数字越大优先级越高。

3.除了可以直接使用数字表示线程的优先级,还可以使用Thread类中提供的三个静态常量表示线程的优先级。

Thread类静态常量 功能描述
static int MAX_PRIORITY 表示线程的最高优先级,相当于值10
static int MIN_PRIORITY 表示线程的最低优先级,相当于值1
static int NORM_PRIORITY 表示线程的普通优先级,相当于值5

4.程序在运行期间,处于就绪状态的每个线程都有自己的优先级,例如main线程具有普通优先级。

5.如何设置线程优先级

通过Thread类setPriority(int newPriority)方法对其进行设置,该方法中的参数newPriority接收的是1~10之间的整数或者Thread类的三个静态常量。

public class ThreadPriorityTest {
    public static void main(String[] args) {
        //创建两个线程,使用Lambda表达式
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println(Thread.currentThread().getName() + "正在执行,我是优先级高的");
            }
        });
        //设置线程优先级10
        thread1.setPriority(10);
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println(Thread.currentThread().getName() + "正在执行,我是优先级低的");
            }
        });
        //设置线程优先级1
        thread2.setPriority(Thread.MIN_PRIORITY);
        thread1.start();
        thread2.start();
    }
}
//输出结果:
//Thread-0正在执行,我是优先级高的
//Thread-1正在执行,我是优先级低的
//Thread-0正在执行,我是优先级高的
//Thread-0正在执行,我是优先级高的
//Thread-0正在执行,我是优先级高的
//Thread-0正在执行,我是优先级高的
//Thread-1正在执行,我是优先级低的
//Thread-1正在执行,我是优先级低的
//Thread-1正在执行,我是优先级低的
//Thread-1正在执行,我是优先级低的

6.注意事项:线程优先级并不能保证优先级低的线程一定后执行,只是概率较低。如果线程执行过程太短、逻辑太简单,基本不存在竞争问题,则根本看不出优先级的作用, 只有在线程足够多的时候才能体现出来。

二、线程休眠和线程让步

线程休眠就是sleep方法,前面已经用到很多次了,这里就不赘述了。

线程让步经常体现不出让步效果,自己看书了解一下吧。

三、线程插队

1.在Thread类中也提供了一个join()方法来实现线程插队功能。

2.当在某个线程中调用其他线程的join()方法时,调用的线程将被阻塞,直到被join()方法加入的线程执行完成后它才会继续运行。

3.Thread类中还提供了带有时间参数的线程插队方法join(long millis)。 当执行带有时间参数的join(long millis)进行线程插队时,必须等待插入的线程指定时间过后才会继续执行其他线程

public class ThreadJoinMethodTest {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                System.out.println(Thread.currentThread().getName() + "输入:" + i);
            }
        });
        thread.start();
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + "输入:" + i);
            //i==5时插入
            if(5 == i){
                try {
                    thread1.join();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
//输出结果:
//main输入:0
//Thread-0输入:0
//main输入:1
//Thread-0输入:1
//main输入:2
//Thread-0输入:2
//main输入:3
//Thread-0输入:3
//main输入:4
//Thread-0输入:4
//main输入:5
//Thread-0输入:5
//Thread-0输入:6
//Thread-0输入:7
//Thread-0输入:8
//Thread-0输入:9
//main输入:6
//main输入:7
//main输入:8
//main输入:9

可以看到,在main线程中,i==5时,Thread-0插队了。

第9课 线程池

一、线程池思想概述

我们使用线程的时候就去创建一个线程,这样实现起来非常简便,但是就会有一个问题:

如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。

那么有没有一种办法使得线程可以复用,就是执行完一个任务,并不被销毁,而是可以继续执行其他的任务?

在Java中可以通过线程池来达到这样的效果。

二、线程池

1. 线程池就是一个容纳多个线程的容器,其中的线程可以反复使用,省去了频繁创建线程对象的操作,无需反复创建线程而消耗过多资源。 从JDK5开始,线程池不需要我们去手动创建集合。

2.线程池的工作原理

3.合理使用线程池的好处

  • 降低资源消耗:减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务。
  • 提高响应速度:当任务到达时,任务可以不需要的等到线程创建就能立即执行。
  • 提高线程的可管理性:可以根据系统的承受能力,调整线程池中工作线线程的数目,防止因为消耗过多的内存,而把服务器累趴下(每个线程需要大约1MB内存,线程开的越多,消耗的内存也就越大,最后死机)。

三、Executor接口实现线程池管理

1.Java中线程池的顶级接口是java.util.concurrent.Executor,但是严格意义上讲Executor并不是一个线程池,而只是一个执行线程的工具真正的线程池接口是java.util.concurrent.ExecutorService

2. 要配置一个线程池是比较复杂的,尤其是对于线程池的原理不是很清楚的情况下,很有可能配置的线程池不是较优的,因此在java.util.concurrent.Executors线程工厂类里面提供了一些静态方法,生成一些常用的线程池。官方建议使用Executors工厂类来创建线程池对象

3.ExecutorService线程池接口

该接口用来从线程池中获取线程,调用start方法,执行线程任务,其中有

  • Future<?> submit(Runnable task)方法: 提交一个 Runnable 任务用于执行,并返回一个表示该任务的Future接口。
  • void shutdown():关闭/销毁线程池。

4. Executors工厂类创建线程池对象的方法(静态方法)

newFixedThreadPool(int nThreads)方法为例,其参数nThreads表示创建线程池中包含的线程数量,返回值是一个ExecutorService接口的实现类对象,我们可以用ExecutorService线程池接口接收这个对象。

5.通过Executor接口实现线程池管理的步骤

  • 使用线程池的Executors工厂类中提供的静态方法创建一个线程池。
  • 创建一个实现Runnable接口或Callable方法的实现类,重写run方法或call方法,设置线程任务。
  • 调用ExecutorService接口中的submit方法,传递线程任务(实现类),开启线程,执行run方法
  • 调用ExecutorService中的shutdown方法销毁线程池(不建议执行)

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
//创建一个实现Runnable接口的实现类,重写run方法,设置线程任务。
class RunnableImpl implements Runnable{
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName()+"创建了一个新的线程执行");
    }
}
public class ThreadPoolTest {
    public static void main(String[] args) {
        //1.使用线程池的Executors工厂类中提供的静态方法创建一个线程池
        //此处使用固定线程数量的线程池,固定只有2个线程
        ExecutorService pool = Executors.newFixedThreadPool(2);
        //3.调用ExecutorService接口中的submit方法,传递线程任务(实现类),开启线程,执行run方法
        //线程池会一直开启,使用完了线程,会自动把线程归还给线程池,线程可以继续使用
        pool.submit(new RunnableImpl());//pool-1-thread-1创建了一个新的线程执行
        pool.submit(new RunnableImpl());//pool-1-thread-1创建了一个新的线程执行
        pool.submit(new RunnableImpl());//pool-1-thread-1创建了一个新的线程执行
        pool.submit(new RunnableImpl());//pool-1-thread-2创建了一个新的线程执行
        //4.调用ExecutorService中的方法shutdown销毁线程池(不建议执行)
        pool.shutdown();

        pool.submit(new RunnableImpl());//抛RejectedExecutionException异常,线程池都没有了,就不能获取线程了
    }
}

第10课 Lambda表达式进阶

面向对象笔记中,我们已经初步了解了Lambda表达式,现在我们将对Java 8的这个重磅新特性进行进一步讲解和练习。如果你还没有阅读过面向对象笔记的Lambda表达式入门,请先阅读入门部分。

一、函数式编程思想

在数学中,函数就是有输入量、输出量的一套计算方案,也就是“拿什么东西做什么事情”。相对而言,面向对象编程过分强调“必须通过对象的形式来做事情”,而函数式思想则尽量忽略面向对象的复杂语法——强调做什么,而不是以什么形式做

面向对象编程思想:做一件事情,找一个能解决这个事情的对象,调用对象的方法,完成事情。

函数式编程思想:只要能获取到结果,谁去做的,怎么做的都不重要,重视的是结果,不重视过程。

二、编程思想的转换

1.让我们回顾一下匿名内部类,对于Runnable接口的匿名内部类用法,可以分析出几点内容:

  • Thread类需要Runnable接口作为参数,其中的抽象run方法是用来指定线程任务内容的核心;
  • 为了指定run的方法体,不得不需要Runnable接口的实现类;
  • 为了省去定义一个RunnableImpl实现类的麻烦,不得不使用匿名内部类;
  • 必须覆盖重写抽象run方法,所以方法名称、方法参数、方法返回值不得不再写一遍,且不能写错;
  • 而实际上,似乎只有方法体才是关键所在

(1) 我们真的希望创建一个匿名内部类对象吗?不。我们只是为了做这件事情而不得不创建一个对象。我们真正希望做的事情是:将run方法体内的代码传递给Thread类知晓。

(2) 传递一段代码——这才是我们真正的目的。而创建对象只是受限于面向对象语法而不得不采取的一种手段方式。那有没有更加简单的办法呢?如果我们将关注点从“怎么做”回归到“做什么”的本质上,就会发现只要能够更好地达到目的,过程与形式其实并不重要。 Java 8中加入了Lambda表达式的重量级新特性,为我们打开了新世界的大门。

三、复习Lambda标准格式

Lambda表达式的更多细节和注意事项请移步面向对象笔记第26课,建议先阅读面向对象笔记的Lambda表达式入门

1.Lambda省去面向对象的条条框框,格式由3个部分组成:

  • 一些参数
  • 一个箭头
  • 一段代码

2.Lambda表达式的标准格式为:

(参数列表) -> {一些重写方法的代码}

3.解释说明格式:

  • ():接口中抽象方法的参数列表,没有参数,就空着;有参数就写出参数,多个参数使用逗号分隔
  • ->:传递的意思,把参数传递给方法体{}
  • {}:重写接口的抽象方法的方法体

四、Lambda表达式练习

1.无参数无返回值(自定义接口)

需求如下:

  • 给定一个厨子Cook接口,内含唯一的抽象方法makeFood,且无参数、无返回值。
  • 使用Lambda的标准格式调用invokeCook方法,打印输出“吃饭啦!”字样

interface Cook{
    void makeFood();
}
public class CookLambdaTest {
    public static void invokeCook(Cook cooker){
        cooker.makeFood();
    }
    public static void main(String[] args) {
        //匿名内部类实现
        invokeCook(new Cook() {
            @Override
            public void makeFood() {
                System.out.println("匿名内部类:开饭咯~");
            }
        });
        //Lambda表达式实现
        invokeCook(()-> System.out.println("Lambda表达式:开饭咯~"));
    }
}
//输出结果:
//匿名内部类:开饭咯~
//Lambda表达式:开饭咯~

2.有参数有返回值

需求如下:

  • 使用数组存储多个Person对象
  • 对数组中的Person对象使用Arrays工具类sort方法通过年龄进行升序排序

import java.util.Arrays;
import java.util.Comparator;

class Person{
    private String name;
    private int age;

    public int getAge() {
        return age;
    }

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return "Person{" +
                "name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

public class PersonLambdaTest {
    public static void main(String[] args) {
        Person[] people = {new Person("张三", 22),
                new Person("李四", 19),
                new Person("王五", 18),
                new Person("赵六", 21),
                new Person("田七", 20),
        };
        //使用匿名内部类重写Arrays类的sort方法
//       Arrays.sort(people, new Comparator<Person>() {
//           @Override
//           public int compare(Person o1, Person o2) {
//               return o1.getAge()-o2.getAge();//o1 - o2为升序
//           }
//       });
        //使用Lambda表达式重写Arrays类的sort方法
        Arrays.sort(people,(p1,p2)->p1.getAge()-p2.getAge());
        for (Person p : people) {
            System.out.println(p);
        }
    }
}
//输出结果:
//Person{name='王五', age=18}
//Person{name='李四', age=19}
//Person{name='田七', age=20}
//Person{name='赵六', age=21}
//Person{name='张三', age=22}

3.有参数有返回值(自定义接口)

需求:自己编写一个计算器Calculator接口,内含一个抽象方法calc,参数为两个int类型的数值,返回值也为int类型。通过Lambda表达式重写该接口的方法为求两数之和,并返回。

interface Calculator {
    int calc(int a, int b);
}

public class CalculatorLambdaTest {
    public static void invokeCalc(int num1, int num2, Calculator c) {
        System.out.println(c.calc(num1, num2));
    }

    public static void main(String[] args) {
        //使用匿名内部类
        invokeCalc(111, 222, new Calculator() {
            @Override
            public int calc(int a, int b) {
                return a + b;
            }
        });//输出333
        //使用Lambda表达式
        invokeCalc(333, 666, (a, b) -> a + b);//输出999
    }
}

第11课 网络编程入门

一、软件结构

C/S结构 :全称为Client/Server结构,是指客户端和服务器结构。常见程序有QQ、迅雷等软件。

B/S结构 :全称为Browser/Server 结构,是指浏览器和服务器结构。常见浏览器有谷歌、火狐等。

两种架构各有优势,但是无论哪种架构,都离不开网络的支持。网络编程,就是在一定的协议下,实现两台计算机的通信的程序。

二、网络通信协议

  • 网络通信协议:通过计算机网络可以使多台计算机实现连接,位于同一个网络中的计算机在进行连接和通信时需要遵守一定的规则,这就好比在道路中行驶的汽车一定要遵守交通规则一样。在计算机网络中,这些连接和通信的规则被称为网络通信协议,它对数据的传输格式、传输速率、传输步骤等做了统一规定,通信双方必须同时遵守才能完成数据交换。

  • TCP/IP协议: 传输控制协议/因特网互联协议( Transmission Control Protocol/Internet Protocol),是Internet最基本、最广泛的协议。它定义了计算机如何连入因特网,以及数据如何在它们之间传输的标准。它的内部包含一系列的用于处理数据通信的协议,并采用了4层的分层模型,每一层都呼叫它的下一层所提供的协议来完成自己的需求。

上图中,TCP/IP协议中的四层分别是应用层、传输层、网络层和链路层,每层分别负责不同的通信功能。

链路层:链路层是用于定义物理传输通道,通常是对某些网络连接设备的驱动协议,例如针对光纤、网线提供的驱动。

网络层:网络层是整个TCP/IP协议的核心,它主要用于将传输的数据进行分组,将分组数据发送到目标计算机或者网络。

运输层:主要使网络程序进行通信,在进行网络通信时,可以采用TCP协议,也可以采用UDP协议。

应用层:主要负责应用程序的协议,例如HTTP协议、FTP协议等。

三、协议分类

通信的协议还是比较复杂的,java.net 包中包含的类和接口,它们提供低层次的通信细节。我们可以直接使用这些类和接口,来专注于网络程序开发,而不用考虑通信的细节。

java.net 包中提供了两种常见的网络协议的支持:

  • UDP:用户数据报协议(User Datagram Protocol)。UDP是无连接通信协议,即在数据传输时,数据的发送端和接收端不建立逻辑连接。简单来说,当一台计算机向另外一台计算机发送数据时,发送端不会确认接收端是否存在,就会发出数据,同样接收端在收到数据时,也不会向发送端反馈是否收到数据。

    由于使用UDP协议消耗资源小,通信效率高,所以通常都会用于音频、视频和普通数据的传输例如视频会议都使用UDP协议,因为这种情况即使偶尔丢失一两个数据包,也不会对接收结果产生太大影响。

    但是在使用UDP协议传送数据时,由于UDP的面向无连接性,不能保证数据的完整性,因此在传输重要数据时不建议使用UDP协议。UDP的交换过程如下图所示。

 

    特点:数据被限制在64KB以内,超出这个范围就不能发送了。

    数据报(Datagram):网络传输的基本单位

  • TCP:传输控制协议 (Transmission Control Protocol)。TCP协议是面向连接的通信协议,即传输数据之前,在发送端和接收端建立逻辑连接,然后再传输数据,它提供了两台计算机之间可靠无差错的数据传输。

    在TCP连接中必须要明确客户端与服务器端,由客户端向服务端发出连接请求,每次连接的创建都需要经过“三次握手”。

    • 三次握手:TCP协议中,在发送数据的准备阶段,客户端与服务器之间的三次交互,以保证连接的可靠。

      • 第一次握手,客户端向服务器端发出连接请求,等待服务器确认。

      • 第二次握手,服务器端向客户端回送一个响应,通知客户端收到了连接请求。

      • 第三次握手,客户端再次向服务器端发送确认信息,确认连接。整个交互过程如下图所示。

 

完成三次握手,连接建立后,客户端和服务器就可以开始进行数据传输了。由于这种面向连接的特性,TCP协议可以保证传输数据的安全,所以应用十分广泛,例如下载文件、浏览网页等。

四、网络编程三要素

1.协议

  • 协议:计算机网络通信必须遵守的规则,已经介绍过了,不再赘述。

2.IP地址

  • IP地址:指互联网协议地址(Internet Protocol Address),俗称IP。IP地址用来给一个网络中的计算机设备做唯一的编号。假如我们把“个人电脑”比作“一台电话”的话,那么“IP地址”就相当于“电话号码”。

(1)IP地址分类

  • IPv4:是一个32位的二进制数,通常被分为4个字节,表示成a.b.c.d 的形式,例如192.168.65.100 。其中a、b、c、d都是0~255之间的十进制整数,那么最多可以表示42亿个。

  • IPv6:由于互联网的蓬勃发展,IP地址的需求量愈来愈大,但是网络地址资源有限,使得IP的分配越发紧张。

    为了扩大地址空间,拟通过IPv6重新定义地址空间,采用128位地址长度,每16个字节一组,分成8组十六进制数,表示成ABCD:EF01:2345:6789:ABCD:EF01:2345:6789,号称可以为全世界的每一粒沙子编上一个IP地址,这样就解决了网络地址资源数量不够的问题。

(2)常用命令

  • 查看本机IP地址,在控制台输入:

ipconfig
  • 检查网络是否连通,在控制台输入:

ping 空格 IP地址
ping 220.181.57.216

(3)特殊的IP地址

  • 本机IP地址:127.0.0.1localhost

3.端口号

网络的通信,本质上是两个进程(应用程序)的通信。每台计算机都有很多的进程,那么在网络通信时,如何区分这些进程呢?

如果说IP地址可以唯一标识网络中的设备,那么端口号就可以唯一标识设备中的进程(应用程序)了。

  • 端口号:用两个字节表示的整数,它的取值范围是0~65535。其中,0~1023之间的端口号用于一些知名的网络服务和应用,普通的应用程序需要使用1024以上的端口号。如果端口号被另外一个服务或应用所占用,会导致当前程序启动失败。

利用协议+IP地址+端口号 三元组合,就可以标识网络中的进程了,那么进程间的通信就可以利用这个标识与其它进程进行交互。

第12课 TCP通信程序

一、概述

TCP通信能实现两台计算机之间的数据交互,通信的两端,要严格区分为客户端(Client)与服务端(Server)。

两端通信时步骤:

  1. 服务端程序,需要事先启动,等待客户端的连接。

  2. 客户端主动连接服务器端,连接成功才能通信。服务端不可以主动连接客户端。

在Java中,提供了两个类用于实现TCP通信程序:

  1. 客户端:java.net.Socket 类表示。创建Socket对象,向服务端发出连接请求,服务端响应请求,两者建立连接开始通信。

  2. 服务端:java.net.ServerSocket 类表示。创建ServerSocket对象,相当于开启一个服务,并等待客户端的连接。

二、Socket类

TCP通信的客户端:向服务器发送连接请求,给服务器发送数据,读取服务器回写的数据。
表示客户端的类:
Socket:此类实现客户端套接字(也可以就叫“套接字”)。套接字是两台机器间通信的端点。

套接字:包含了IP地址和端口号的网络单位

1.构造方法

java.net.Socket(String host, int port) 创建一个流套接字并将其连接到指定主机上的指定端口号。
参数:
String host:服务器主机的名称/服务器的IP地址
int port:服务器的端口号

2.成员方法

OutputStream getOutputStream() 返回此套接字的输出流。
InputStream getInputStream() 返回此套接字的输入流。
void close() 关闭此套接字。

3.实现步骤

1.创建一个客户端对象Socket,构造方法绑定服务器的IP地址和端口号

2.使用Socket对象中的方法getOutputStream()获取网络字节输出流OutputStream对象

3.使用网络字节输出流OutputStream对象中的方法write,给服务器发送数据

4.使用Socket对象中的方法getInputStream()获取网络字节输入流InputStream对象

5.使用网络字节输入流InputStream对象中的方法read,读取服务器回写的数据

6.释放资源(Socket)
4.注意事项

1.客户端和服务器端进行交互,必须使用Socket中提供的网络流,不能使用自己创建的流对象。

2.当我们创建客户端对象Socket的时候,就会去请求服务器和服务器经过3次握手建立连接通路。

  • 这时如果服务器没有启动,那么就会抛出异常:ConnectException: Connection refused: connect。
  • 如果服务器已经启动,那么就可以进行交互了。

三、ServerSocket类

TCP通信的服务器端:接收客户端的请求,读取客户端发送的数据,给客户端回写数据
表示服务器的类:
java.net.ServerSocket:此类实现服务器套接字。

1.构造方法

ServerSocket(int port)创建绑定到特定端口的服务器套接字。

2.成员方法

Socket accept()侦听并接受到此套接字的连接。

服务器端必须明确一件事情:必须知道是哪个客户端请求的服务器,所以可以使用accept方法获取到请求的客户端对象Socket。

3.服务器的实现步骤

1.创建服务器ServerSocket对象和系统要指定的端口号
2.使用ServerSocket对象中的方法accept,获取到请求的客户端对象Socket
3.使用Socket对象中的方法getInputStream()获取网络字节输入流InputStream对象
4.使用网络字节输入流InputStream对象中的方法read,读取客户端发送的数据
5.使用Socket对象中的方法getOutputStream()获取网络字节输出流OutputStream对象
6.使用网络字节输出流OutputStream对象中的方法write,给客户端回写数据
7.释放资源(Socket,ServerSocket)

四、代码演示

TCP服务器端代码

public class TCPServer {
    public static void main(String[] args) {
        try (//1.创建服务器ServerSocket对象和系统要指定的端口号
             ServerSocket server = new ServerSocket(8888);
        ) {
            //2.使用ServerSocket对象中的方法accept,获取到请求的客户端对象Socket
            Socket socket = server.accept();
            //3.使用Socket对象中的方法getInputStream()获取网络字节输入流InputStream对象
            InputStream is = socket.getInputStream();
            //4.使用网络字节输入流InputStream对象中的方法read,读取客户端发送的数据
            byte[] bytes = new byte[1024];
            int b = is.read(bytes);
            String s = new String(bytes, 0, b);
            System.out.println("server accepted:" + s);
            //5.使用Socket对象中的方法getOutputStream()获取网络字节输出流OutputStream对象
            OutputStream os = socket.getOutputStream();
            //6.使用网络字节输出流OutputStream对象中的方法write,给客户端回写数据
            os.write("Hello,Client".getBytes());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

TCP客户端代码

public class TCPClient {
    public static void main(String[] args) {
        try (//1.创建一个客户端对象Socket,构造方法绑定服务器的IP地址和端口号
             Socket socket = new Socket("localhost", 8888);
        ) {
            //2.使用Socket对象中的方法getOutputStream()获取网络字节输出流OutputStream对象
            OutputStream os = socket.getOutputStream();
            //3.使用网络字节输出流OutputStream对象中的方法write,给服务器发送数据
            os.write("Hello,Server".getBytes());
            //4.使用Socket对象中的方法getInputStream()获取网络字节输入流InputStream对象
            InputStream is = socket.getInputStream();
            //5.使用网络字节输入流InputStream对象中的方法read,读取服务器回写的数据
            byte[] bytes = new byte[1024];
            int b = is.read(bytes);
            String s = new String(bytes, 0, b);
            System.out.println("client accepted:" + s);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

先运行TCPServer.main(),再运行TCPClient.main()后,输出结果如下:

TCPServer控制台显示:server accepted:Hello,Server
TCPClient控制台显示:client accepted:Hello,Client

第13课 文件上传

一、文件上传和服务器回写原理分析

  1. 【客户端】本地字节输入流,从硬盘读取文件数据到程序中。

  2. 【客户端】网络字节输出流,写出文件数据到服务端。

  3. 【服务端】网络字节输入流,读取文件数据到服务端程序。

  4. 【服务端】本地输出流,写出文件数据到服务器硬盘中。

  1. 【服务端】网络字节输出流,回写数据。

  2. 【客户端】网络字节输入流,解析回写数据。

注意:

1.客户端/服务器和本地磁盘进行读写,需要使用自己创建的字节流对象(本地流)。

2.客户端和服务器之间进行读写,必须使用Socket类中提供的字节流对象。

3.文件上传的原理,就是文件的复制

明确:数据源和数据目的地

二、文件上传案例

1.文件上传案例的客户端:读取本地文件,上传到服务器,读取服务器回写的数据

2.明确
数据源:本地文件路径
目的地:服务器
3.实现步骤(客户端)
1.创建一个本地字节输入流FileInputStream对象,构造方法中绑定要读取的数据源
2.创建一个客户端Socket对象,构造方法中绑定服务器的IP地址和端口号
3.使用Socket中的方法getOutputStream,获取网络字节输出流OutputStream对象
4.使用本地字节输入流FileInputStream对象中的方法read,读取本地文件
5.使用网络字节输出流OutputStream对象中的方法write,把读取到的文件上传到服务器

6.上传完文件,使用以下方法给服务器写一个结束标记。否则会导致服务端的InputStream对象中的方法read方法始终读取不到结束标记,陷入死循环。

void shutdownOutput() 禁用此套接字的输出流。
对于 TCP 套接字,任何以前写入的数据都将被发送,并且后跟 TCP 的正常连接终止序列。
7.使用Socket中的方法getInputStream,获取网络字节输入流InputStream对象
8.使用网络字节输入流InputStream对象中的方法read读取服务回写的数据
9.释放资源(FileInputStream,Socket)

4.实现步骤(服务端)

1.创建一个服务器ServerSocket对象,和系统要指定的端口号
2.使用ServerSocket对象中的方法accept,获取到请求的客户端Socket对象
3.使用Socket对象中的方法getInputStream,获取到网络字节输入流InputStream对象
4.判断目标文件夹是否存在,不存在则创建
5.创建一个本地字节输出流FileOutputStream对象,构造方法中绑定要输出的目的地
6.使用网络字节输入流InputStream对象中的方法read,读取客户端上传的文件
7.使用本地字节输出流FileOutputStream对象中的方法write,把读取到的文件保存到服务器的硬盘上
8.使用Socket对象中的方法getOutputStream,获取到网络字节输出流OutputStream对象
9.使用网络字节输出流OutputStream对象中的方法write,给客户端回写"上传成功"
10.释放资源(FileOutputStream,Socket,ServerSocket)

代码演示(把文件从客户端上传到服务端):

TCP客户端代码

public class TCPClient {
    public static void main(String[] args) {
        try (//1.创建一个本地字节输入流FileInputStream对象,构造方法中绑定要读取的数据源
             FileInputStream fis = new FileInputStream("Net/A.txt");
             //2.创建一个客户端Socket对象,构造方法中绑定服务器的IP地址和端口号
             Socket socket = new Socket("localhost", 8888);
        ) {
            //3.使用Socket中的方法getOutputStream,获取网络字节输出流OutputStream对象
            OutputStream os = socket.getOutputStream();
            //4.使用本地字节输入流FileInputStream对象中的方法read,读取本地文件
            byte[] bytes = new byte[1024];
            int b;
            while ((b = fis.read(bytes)) != -1) {
                //5.使用网络字节输出流OutputStream对象中的方法write,把读取到的文件上传到服务器
                os.write(bytes, 0, b);
            }
            //6.上传完文件,给服务器写一个结束标记。
            socket.shutdownOutput();
            //7.使用Socket中的方法getInputStream,获取网络字节输入流InputStream对象
            InputStream is = socket.getInputStream();
            //8.使用网络字节输入流InputStream对象中的方法read读取服务回写的数据
            while ((b = is.read(bytes)) != -1) {
                System.out.println(new String(bytes, 0, b));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

TCP服务端代码

public class TCPServer {
    public static void main(String[] args) {
        try (//1.创建一个服务器ServerSocket对象,和系统要指定的端口号
             ServerSocket server = new ServerSocket(8888);
        ) {
            //2.使用ServerSocket对象中的方法accept,获取到请求的客户端Socket对象
            Socket socket = server.accept();
            //3.使用Socket对象中的方法getInputStream,获取到网络字节输入流InputStream对象
            InputStream is = socket.getInputStream();
            //4.判断文件夹是否存在,不存在则创建
            File file = new File("Net/uploads/");
            if (!file.exists()){
                file.mkdirs();
            }
            //5.创建一个本地字节输出流FileOutputStream对象,构造方法中绑定要输出的目的地
            FileOutputStream fos = new FileOutputStream("Net/uploads/1.txt");
            //6.使用网络字节输入流InputStream对象中的方法read,读取客户端上传的文件
            byte[] bytes = new byte[1024];
            int b;
            while ((b = is.read(bytes)) != -1) {
                //7.使用本地字节输出流FileOutputStream对象中的方法write,把读取到的文件保存到服务器的硬盘上
                fos.write(bytes,0,b);
            }
            //8.使用Socket对象中的方法getOutputStream,获取到网络字节输出流OutputStream对象
            OutputStream os = socket.getOutputStream();
            //9.使用网络字节输出流OutputStream对象中的方法write,给客户端回写"上传成功"
            os.write("上传成功".getBytes());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

三、文件上传案例优化

1.文件名称写死的问题

服务端,保存文件的名称如果写死,那么最终导致服务器硬盘,只会保留一个文件,建议使用系统时间优化,保证文件名称唯一,代码如下:

FileOutputStream fis = new FileOutputStream(System.currentTimeMillis()+".txt") // 文件名称BufferedOutputStream bos = new BufferedOutputStream(fis);

2.循环接收的问题

服务端,指保存一个文件就关闭了,之后的用户无法再上传,这是不符合实际的,使用循环改进,可以不断的接收不同用户的文件,代码如下:

// 每次接收新的连接,创建一个Socket
while(true){
    Socket accept = serverSocket.accept();
    ......
}

3.效率问题

服务端,在接收大文件时,可能耗费几秒钟的时间,此时不能接收其他用户上传,所以,使用多线程技术优化,代码如下:

while(true){
    Socket accept = serverSocket.accept();
    // accept 交给子线程处理.
    new Thread(() -> {
      	......
        InputStream bis = accept.getInputStream();
      	......
    }).start();
}

优化后的TCP服务器端代码如下,使用了多线程技术,并结合了匿名内部类。TCP客户端代码不变。

public class TCPServer {
    public static void main(String[] args) {
        try (//1.创建一个服务器ServerSocket对象,和系统要指定的端口号
             ServerSocket server = new ServerSocket(8888);
        ) {
            /*
                让服务器一直处于监听状态(死循环accept方法)
                有一个客户端上传文件,就保存一个文件
            */
            while (true) {
                //2.使用ServerSocket对象中的方法accept,获取到请求的客户端Socket对象
                Socket socket = server.accept();
                /*
                    使用多线程技术,提高程序的效率,使用了匿名内部类
                    有一个客户端上传文件,就开启一个线程,完成文件的上传
                */
                new Thread(new Runnable() {
                    public void run() {
                        try {
                            //3.使用Socket对象中的方法getInputStream,获取到网络字节输入流InputStream对象
                            InputStream is = socket.getInputStream();
                            //4.判断文件夹是否存在,不存在则创建
                            File file = new File("Net/uploads");
                            if (!file.exists()) {
                                file.mkdirs();
                            }
                            //5.创建一个本地字节输出流FileOutputStream对象,构造方法中绑定要输出的目的地
                            /*
                                自定义一个文件的命名规则:防止同名的文件被覆盖
                                规则:域名+毫秒值
                            */
                            String filename = "anonyeast" + System.currentTimeMillis() + ".txt";
                            FileOutputStream fos = new FileOutputStream(file + "/" + filename);
                            //6.使用网络字节输入流InputStream对象中的方法read,读取客户端上传的文件
                            byte[] bytes = new byte[1024];
                            int b;
                            while ((b = is.read(bytes)) != -1) {
                                //7.使用本地字节输出流FileOutputStream对象中的方法write,把读取到的文件保存到服务器的硬盘上
                                fos.write(bytes, 0, b);
                            }
                            //8.使用Socket对象中的方法getOutputStream,获取到网络字节输出流OutputStream对象
                            OutputStream os = socket.getOutputStream();
                            //9.使用网络字节输出流OutputStream对象中的方法write,给客户端回写"上传成功"
                            os.write("上传成功".getBytes());
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }).start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

第14课 Junit单元测试

一、测试分类
        1. 黑盒测试:不需要写代码,给输入值,看程序是否能够输出期望的值。不考虑程序内部的逻辑。
        2.白盒测试:需要写代码的。关注程序具体的执行流程。

二、Junit单元测试

之前写测试类都是通过main()方法来进行测试,这种办法有很多缺陷,例如一个类只能有一个main方法,一次只能测试一个方法等诸多问题。本讲学习的Junit单元测试将讲解如何通过Junit工具写测试类。

Junit使用:白盒测试
1.使用步骤
            1. 定义一个测试类(测试用例)
                * 建议:
                    * 测试类名:被测试的类名Test    例如:CalculatorTest
                    * 包名:xxx.xxx.xx.test        例如:top.anonyeast.test

            2. 定义测试方法:每个测试方法可以独立运行
                * 建议:
                    * 方法名:test测试的方法名        例如:testAdd()  
                    * 返回值:void
                    * 参数列表:空参
                    * 因为测试方法我们只需要它独立运行,然后观察结果,并不需要某个方法去调用它,因此无返回值空参数最为合适。

            3. 给测试方法加@Test
            4. 导入junit依赖环境(jar包),否则无法执行测试方法。

2.判定结果
            * 红色:失败
            * 绿色:成功
            * 一般我们会使用断言操作来处理结果,期望结果与运算结果相同则测试通过,否则不通过。
                * Assert.assertEquals(期望的结果,运算的结果)
            * 一般以判定结果作为测试是否通过的标准,而不是通过肉眼观察测试结果是否正确

3.代码演示

计算器类,测试加法和减法是否正确

public class CalculatorTest {
    /**
     * 测试add方法(加法)
     */
    @Test
    public void testAdd(){
        //1.创建计算器对象
        Calculator c = new Calculator();
        //2.调用add方法
        int result = c.add(1, 2);
        //3.断言 我断言这个结果是3
        //Assert.assertEquals(期望的结果,运算的结果)
        Assert.assertEquals(3,result);
    }
    /**
     * 测试sub方法
     */
    @Test
    public void testSub(){
        //1.创建计算器对象
        Calculator c = new Calculator();
        //2.调用sub方法
        int result = c.sub(1, 2);
        //3.断言 我断言这个结果是-1
        //Assert.assertEquals(期望的结果,运算的结果)
        Assert.assertEquals(-1,result);
    }
}
测试结果:两个方法均测试通过

三、Before和After注解

 @Before:通过@Before修饰的方法会在测试方法之前被自动执行。通常用于初始化(init)方法,比如申请资源。
 @After:通过@After修饰的方法会在所有测试方法执行之后被自动执行。通常用于结束(finally)方法,比如释放资源。

第15课 反射

一、反射:框架设计的灵魂
* 框架:半成品软件。可以在框架的基础上进行软件开发,简化编码。比如一个项目不用框架要10000行代码,用了框架只需要1000行就写好了,因为有9000行框架已经帮你写了。

* 反射:将类的各个组成部分封装为其他对象,这就是反射机制
    * 好处:
        1. 可以在程序运行过程中,操作这些对象。
        2. 可以解耦,提高程序的可扩展性。

AnonyEast

一个爱折腾的技术萌新

留下你的评论

*评论支持代码高亮<pre class="prettyprint linenums">代码</pre>

相关推荐