文章目录
1.线程介绍
线程是比进程更小的能独立运行的基本单位,它是进程的一部分,一个进程可以拥有多个线程,但至少要有一个线程,即主执行线程(Java 的 main 方法)。我们既可以编写单线程应用,也可以编写多线程应用。
一个进程中的多个线程可以并发(同时)执行,在一些执行时间长、需要等待的任务上(例如:文件读写和网络传输等),多线程就比较有用了。
怎么理解多线程呢?来两个例子:
- 进程就是一个工厂,一个线程就是工厂中的一条生产线,一个工厂至少有一条生产线,只有一条生产线就是单线程应用,拥有多条生产线就是多线程应用。多条生产线可以同时运行。
- 我们使用迅雷可以同时下载多个视频,迅雷就是进程,多个下载任务就是线程,这几个线程可以同时运行去下载视频。
多线程可以共享内存、充分利用 CPU,通过提高资源(内存和 CPU)使用率从而提高程序的执行效率。CPU 使用抢占式调度模式在多个线程间进行着随机的高速的切换。对于 CPU的一个核而言,某个时刻,只能执行一个线程,而 CPU 在多个线程间的切换速度相对我们的感觉要快很多,看上去就像是多个线程或任务在同时运行。
Java 天生就支持多线程并提供了两种编程方式,一个是继承 Thread 类,一个是实现Runnable 接口。更多的具体介绍看Java基础(14)——多线程、线程安全
另外有一个很重要的点就是:
任何的java对象都可以是锁对象
2.线程间的通信
多个线程并发执行时, 在默认情况下 CPU 是随机性的在线程之间进行切换的,但是有时候我们希望它们能有规律的执行, 那么,多线程之间就需要一些协调通信来改变或控制 CPU的随机性。Java 提供了等待唤醒机制来解决这个问题,具体来说就是多个线程依靠一个同步锁,然后借助于 wait()和 notify()方法就可以实现线程间的协调通信。
同步锁相当于中间人的作用,多个线程必须用同一个同步锁(认识同一个中间人),只有同一个锁上的被等待的线程,才可以被持有该锁的另一个线程唤醒,使用不同锁的线程之间不能相互唤醒,也就无法协调通信。
Java 在 Object 类中提供了一些方法可以用来实现线程间的协调通信,我们一起来了解
一下:
public final void wait(); 让当前线程释放锁
public final native void wait(long timeout); 让当前线程释放锁,并等待 xx 毫秒
public final native void notify(); 唤醒持有同一锁的某个线程
public final native void notifyAll(); 唤醒持有同一锁的所有线程
需要注意的是:在调用 wait 和 notify 方法时,当前线程必须已经持有锁,然后才可以调用,否则将会抛出 IllegalMonitorStateException 异常。接下来咱们通过两个案例来演示一下具体如何编程实现线程间通信。
案例 1: 一个线程输出 10 次 1,一个线程输出 10 次 2,要求交替输出“1 2 1 2 1 2…”或“2 12 1 2 1…”
代码演示:
public class MyLock {
public static Object o = new Object();
}
为了保证两个线程使用的一定是同一个锁,我们创建一个对象作为静态属性放到一个类中,这个对象就用来充当锁。
public class ThreadForNum1 extends Thread {
@Override
public void run() {
for (int i = 0;i < 10;i++){
synchronized (MyLock.o){
//干完自己的活了,叫醒别人,然后自己休息
System.out.println("1");
MyLock.o.notify();
try {
MyLock.o.wait();//wait方法把锁对象释放
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
该线程输出十次 1,使用 MyLock.o 作为锁,每输出一个 1 就唤醒另一个线程,然后自己休眠并释放锁。
public class ThreadForNum2 extends Thread {
@Override
public void run() {
for (int i = 0;i < 10;i++){
synchronized (MyLock.o){
System.out.println("2");
MyLock.o.notify();
try {
MyLock.o.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
该线程输出十次 2,也使用 MyLock.o 作为锁,每输出一个 2 就唤醒另一个线程,然后自己休眠并释放锁。
public class ThreadPrintForNum1_2 {
public static void main(String[] args) {
new ThreadForNum1().start();
new ThreadForNum2().start();
}
}
输出效果:
案例 2:生产者消费者模式
该模式在现实生活中很常见,在项目开发中也广泛应用,它是线程间通信的经典应用。生产者是一堆线程,消费者是另一堆线程,内存缓冲区可以使用 List 集合存储数据。该模式的关键之处是如何处理多线程之间的协调通信,内存缓冲区为空的时候,消费者必须等待,而内存缓冲区满的时候,生产者必须等待,其他时候可以是个动态平衡。
下面的案例模拟实现农夫采摘水果放到筐里,小孩从筐里拿水果吃,农夫是一个线程,小孩是一个线程,水果筐放满了,农夫停;水果筐空了,小孩停。
public class OurCollector {
public static List<String> collector = new ArrayList<>();
}
public class Farmer extends Thread {
//农民生产水果
@Override
public void run() {
while (true){
synchronized (OurCollector.collector){
if(OurCollector.collector.size() == 10){
//如果水果筐现在达到了最大的容量,农民先休息
try {
OurCollector.collector.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//农民开始生产水果
OurCollector.collector.add("apple");
System.out.println("农民生产了苹果,现在框里有苹果: " + OurCollector.collector.size() + " 个");
//生产好水果之后叫小孩来吃
OurCollector.collector.notify();
//模拟生产的时间
try {
Thread.sleep(40);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
public class Child extends Thread {
@Override
public void run() {
while (true){
synchronized (OurCollector.collector){
if(OurCollector.collector.size() == 0){
//如果水果筐现在没有水果了,小孩先休息
try {
OurCollector.collector.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//小孩开始吃水果
OurCollector.collector.remove("apple");
System.out.println("小孩吃了苹果,现在框里有苹果: " + OurCollector.collector.size() + " 个");
//吃了水果之后叫农民继续生产
OurCollector.collector.notify();
//模拟吃的的时间
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
public class TestProductAndCustomer {
public static void main(String[] args) {
new Farmer().start();
new Child().start();
}
}
我们创建两个线程同时运行,可以通过双方线程里的 sleep 方法模拟控制速度,当农夫往框里放水果的速度快于小孩吃水果的速度时,运行效果如下图所示:
当小孩吃水果的速度快于农夫往框里放水管的速度时,运行效果如下图所示:
3.BIO编程
BIO 有的称之为 basic(基本) IO,有的称之为 block(阻塞) IO,主要应用于文件 IO 和网络 IO,这里不再说文件 IO, 更多了解io流。
在 JDK1.4 之前,我们建立网络连接的时候只能采用 BIO,需要先在服务端启动一个ServerSocket,然后在客户端启动 Socket 来对服务端进行通信,默认情况下服务端需要对每个请求建立一个线程等待请求,而客户端发送请求后,先咨询服务端是否有线程响应,如果没有则会一直等待或者遭到拒绝,如果有的话,客户端线程会等待请求结束后才继续执行,这就是阻塞式 IO。
接下来通过一个例子复习回顾一下 BIO 的基本用法(基于 TCP)。
//BIO 服务器端程序
public class TCPServer {
public static void main(String[] args) throws Exception {
//1.创建 ServerSocket 对象
ServerSocket ss=new ServerSocket(9999);
while (true) {
//2.监听客户端
Socket s = ss.accept(); //阻塞
//3.从连接中取出输入流来接收消息
InputStream is = s.getInputStream(); //阻塞
byte[] b = new byte[10];
is.read(b);
String clientIP = s.getInetAddress().getHostAddress();
System.out.println(clientIP + "说:" + new String(b).trim());
//4.从连接中取出输出流并回话
OutputStream os = s.getOutputStream();
os.write("没钱".getBytes());
//5.关闭
s.close();
}
}
}
上述代码编写了一个服务器端程序,绑定端口号 9999,accept 方法用来监听客户端连接,如果没有客户端连接,就一直等待,程序会阻塞到这里。
//BIO 客户端程序
public class TCPClient {
public static void main(String[] args) throws Exception {
while (true) {
//1.创建 Socket 对象
Socket s = new Socket("127.0.0.1", 9999);
//2.从连接中取出输出流并发消息
OutputStream os = s.getOutputStream();
System.out.println("请输入:");
Scanner sc = new Scanner(System.in);
String msg = sc.nextLine();
os.write(msg.getBytes());
//3.从连接中取出输入流并接收回话
InputStream is = s.getInputStream(); //阻塞
byte[] b = new byte[20];
is.read(b);
System.out.println("老板说:" + new String(b).trim());
//4.关闭
s.close();
}
}
}
上述代码编写了一个客户端程序,通过 9999 端口连接服务器端,getInputStream 方法用来等待服务器端返回数据,如果没有返回,就一直等待,程序会阻塞到这里。运行效果如下图所示:
4.NIO编程
4.1概述
java.nio 全称 java non-blocking IO,是指 JDK 提供的新 API。从 JDK1.4 开始,Java 提供了一系列改进的输入/输出的新特性,被统称为 NIO(即 New IO)。新增许多用于处理输入输出的类,这些类都被放在 java.nio 包及子包下,并且对原java.io 包中的很多类进行改写,新增了满足 NIO 的功能。
NIO 和 BIO 有着相同的目的和作用,但是它们的实现方式完全不同,BIO 以流的方式处理数据,而 NIO 以块的方式处理数据,块 I/O 的效率比流 I/O 高很多。另外,NIO 是非阻塞式的,这一点跟 BIO 也很不相同,使用它可以提供非阻塞式的高伸缩性网络。
**NIO 主要有三大核心部分:Channel(通道),Buffer(缓冲区), Selector(选择器)。**传统的 BIO基于字节流和字符流进行操作,而 NIO 基于 Channel(通道)和 Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中Selector (选择区)用于监听多个通道的事件(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道。
4.2 文件 IO
4.2.1 概述和核心 API
缓冲区(Buffer):实际上是一个容器,是一个特殊的数组,缓冲区对象内置了一些机制,能够跟踪和记录缓冲区的状态变化情况。Channel 提供从文件、网络读取数据的渠道,但是读取或写入的数据都必须经由 Buffer,如下图所示:
在 NIO 中,Buffer 是一个顶层父类,它是一个抽象类,常用的 Buffer 子类有:
ByteBuffer,存储字节数据到缓冲区
ShortBuffer,存储字符串数据到缓冲区
CharBuffer,存储字符数据到缓冲区
IntBuffer,存储整数数据到缓冲区
LongBuffer,存储长整型数据到缓冲区
DoubleBuffer,存储小数到缓冲区
FloatBuffer,存储小数到缓冲区
对于 Java 中的基本数据类型,都有一个 Buffer 类型与之相对应,最常用的自然是
ByteBuffer 类(二进制数据),其作用类似于普通文件读写中的byte数组,该类的主要方法如下所示:
public abstract ByteBuffer put(byte[] b); 存储字节数据到缓冲区
public abstract byte[] get(); 从缓冲区获得字节数据
public final byte[] array(); 把缓冲区数据转换成字节数组
public static ByteBuffer allocate(int capacity); 设置缓冲区的初始容量
public static ByteBuffer wrap(byte[] array); 把一个现成的数组放到缓冲区中使用
public final Buffer flip(); 翻转缓冲区,重置位置到初始位置
通道(Channel):类似于 BIO 中的 stream,例如 FileInputStream 对象,用来建立到目标(文件,网络套接字,硬件设备等)的一个连接,但是需要注意:BIO 中的 stream 是单向的,例如 FileInputStream 对象只能进行读取数据的操作,而 NIO 中的通道(Channel)是双向的,既可以用来进行读操作,也可以用来进行写操作。常用的 Channel 类有:FileChannel、DatagramChannel、ServerSocketChannel 和 SocketChannel。FileChannel 用于文件的数据读写,DatagramChannel 用于 UDP 的数据读写,ServerSocketChannel 和 SocketChannel 用于 TCP 的数据读写。
这里我们先讲解 FileChannel 类,该类主要用来对本地文件进行 IO 操作,主要方法如下所示:
public int read(ByteBuffer dst) ,从通道读取数据并放到缓冲区中
public int write(ByteBuffer src) ,把缓冲区的数据写到通道中
public long transferFrom(ReadableByteChannel src, long position, long count),从目标通道中复制数据到当前通道
public long transferTo(long position, long count, WritableByteChannel target),把数据从当前通道复制给目标通道
4.2.2 案例
接下来我们通过 NIO 实现几个案例,分别演示一下本地文件的读、写和复制操作,并和 BIO 做个对比。
1. 往本地文件中写数据
@Test //往文件里边写数据
public void test1() throws Exception{
//1.创建一个输出流对象,用以往本地文件中写数据
FileOutputStream fos = new FileOutputStream("basic.txt");
//2.从流对象中获取通道
FileChannel channel = fos.getChannel();
//3.准备一个缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
//4.准备数据,往缓冲区中写数据
String s = "hello nio!";
buffer.put(s.getBytes());
//5.把缓冲区翻转一下,再把数据写进通道中
buffer.flip();
//6.把数据从缓冲区写进通道中去
channel.write(buffer);
//7.关闭流
fos.close();
}
NIO 中的通道是从输出流对象里通过 getChannel 方法获取到的,该通道是双向的,既可以读,又可以写。在往通道里写数据之前,必须通过 put 方法把数据存到 ByteBuffer 中,然后通过通道的 write 方法写数据。在 write 之前,需要调用 flip 方法翻转缓冲区,把内部重置到初始位置,这样在接下来写数据时才能把所有数据写到通道里。运行效果如下图所示:
2. 从本地文件中读数据
@Test //从本地文件中读取数据
public void test2() throws Exception{
File file = new File("basic.txt");
//1.创建一个文件输入流对象
FileInputStream fis = new FileInputStream(file);
//2.从文件输入流中获取通道
FileChannel fileChannel = fis.getChannel();
//3.准备一个缓冲区
ByteBuffer buffer = ByteBuffer.allocate((int) file.length());
//4.把通道中的内容读取出来到缓冲区中
fileChannel.read(buffer);
//5.读取好内容到缓冲区之后,在控制台打印内容
System.out.println(new String(buffer.array()));
//6.关闭输入流
fis.close();
}
上述代码从输入流中获得一个通道,然后提供 ByteBuffer 缓冲区,该缓冲区的初始容量和文件的大小一样,最后通过通道的 read 方法把数据读取出来并存储到了 ByteBuffer 中。
运行效果如下图所示:
3.复制文件
通过 BIO 复制一个视频文件,代码如下所示:
@Test //使用传统的方式来复制文件
public void test4() throws Exception{
//1.先准备两个流对象
FileInputStream fis = new FileInputStream("basic.txt");
FileOutputStream fos = new FileOutputStream("copy_basic2.txt");
//2.准备一个字节数组
byte[] bytes = new byte[1024];
//3.进行赋值操作,一边读一边写
while (true) {
int read = fis.read(bytes);
if(read == -1){
break;
}
fos.write(bytes,0,read);
}
//4.关闭流对象
fis.close();
fos.close();
}
上述代码分别通过输入流和输出流实现了文件的复制,这是通过传统的 BIO 实现的,大家都比较熟悉,不再多说。
通过 NIO 复制相同的文件,代码如下所示:
@Test //演示nio复制文件操作
public void test3() throws Exception{
//1.先准备两个流对象
FileInputStream fis = new FileInputStream("basic.txt");
FileOutputStream fos = new FileOutputStream("copy_basic.txt");
//2.获取两个流的通道
FileChannel sourceChannel = fis.getChannel();
FileChannel destChannel = fos.getChannel();
//3.直接调用通道方法复制
sourceChannel.transferTo(0,sourceChannel.size(),destChannel);
// destChannel.transferFrom(sourceChannel,0,sourceChannel.size());//两种方法都可以复制的
//4.关闭流
fis.close();
fos.close();
}
上述代码分别从两个流中得到两个通道,sourceCh 负责读数据,destCh 负责写数据,然后直接调用 transferFrom 方法一步到位实现了文件复制。