Java进阶(1)——多线程通信与NIO

1.线程介绍

线程是比进程更小的能独立运行的基本单位,它是进程的一部分,一个进程可以拥有多个线程,但至少要有一个线程,即主执行线程(Java 的 main 方法)。我们既可以编写单线程应用,也可以编写多线程应用。
一个进程中的多个线程可以并发(同时)执行,在一些执行时间长、需要等待的任务上(例如:文件读写和网络传输等),多线程就比较有用了。
怎么理解多线程呢?来两个例子:

  1. 进程就是一个工厂,一个线程就是工厂中的一条生产线,一个工厂至少有一条生产线,只有一条生产线就是单线程应用,拥有多条生产线就是多线程应用。多条生产线可以同时运行。
  2. 我们使用迅雷可以同时下载多个视频,迅雷就是进程,多个下载任务就是线程,这几个线程可以同时运行去下载视频。

多线程可以共享内存、充分利用 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 方法一步到位实现了文件复制。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值