JUC并发编程01 - 进程与线程/创建线程

笔记参考:JavaNote/Prog.md at main · Seazean/JavaNote

进程概述

进程:程序是静止的,进程实体的运行过程就是进程,是系统进行资源分配的基本单位

用大白话来解释:

程序 就像是你手机里下载的一个App,比如“微信”。它就静静地躺在手机里,是一个文件,没在干活。但当你点开微信,它开始运行了——这时候,系统就给这个“正在运行的微信”创建了一个“活”的东西,这个“活”的东西就叫进程。所以说,程序是“死”的,进程是“活”的。程序是代码文件,而进程是这个程序跑起来之后的整个过程。而且,电脑(或手机)在分配资源,比如CPU时间、内存空间的时候,不是按程序来分的,而是按进程来分的。比如微信进程占多少内存,抖音进程占多少CPU,系统是按“进程”来管理的。

打开你电脑的任务管理器,就会发现一堆进程:

进程的特征有:

  • 并发性

意思是多个进程可以“看起来”同时运行。比如你一边听音乐,一边刷抖音,一边微信聊天。其实CPU在快速切换,让你感觉它们是同时进行的。

  • 异步性

每个进程都是“自己走自己的路”,谁也不知道它什么时候能执行完。比如你发个微信,可能马上发出去,也可能卡一下,系统不会让所有进程步调一致。

  • 动态性

进程是“活”的,它有“生老病死”:创建 → 运行 → 暂停 → 结束。不像程序,只是一个文件,一直躺在那里。

  • 独立性

每个进程都是独立的“小个体”,它有自己的内存空间,不会随便被别的进程干扰。比如抖音崩溃了,微信一般不会跟着挂掉。

  • 结构性

进程不是“一团乱麻”,它是由几部分组成的,比如:程序代码(要执行的指令),数据(程序用到的信息),进程控制块(PCB,操作系统用来管理这个进程的“身份证”和“档案”)

线程概述

线程是属于进程的,是一个基本的 CPU 执行单元,是程序执行流的最小单元。

进程就像是一个大工厂,而线程就是这个工厂里的工人。每个工厂(进程)里可以有多个工人(线程),这些工人一起工作才能让工厂运作起来。线程是CPU执行任务的基本单位,也就是说,CPU一次只执行一小段代码,这小段代码就由一个线程负责。它是程序中能够单独运行的最小部分。

线程本身不拥有系统资源,只拥有一点在运行中必不可少的资源,与同属一个进程的其他线程共享进程所拥有的全部资源。

每个工人(线程)自己手里只有很少的东西,比如自己的工具包,这是他们必须的工作用品(线程必需的资源)。但是整个工厂(进程)里所有的设备、材料等大部分资源是所有工人共享的。而且,老板(系统)会根据需要单独给每个工人安排任务,而不是直接指挥整个工厂。

关系:一个进程可以包含多个线程,这就是多线程,比如看视频是进程,图画、声音、广告等就是多个线程。

继续上面的比喻,如果你在看一个视频(这是一个进程),那么画面显示(一个线程)、声音播放(另一个线程)、广告加载(又一个线程)都是不同的工人在做不同的事情,但他们都属于同一个工厂(进程)。

线程的作用:使多道程序更好的并发执行,提高资源利用率和系统吞吐量,增强操作系统的并发性能。

相当于工厂流水线:使用多线程就像在同一时间让多个工人同时工作,这样不仅能让工厂更高效地生产,还能充分利用工厂里的资源。

并发与并行

  • 并行:在同一时刻,有多个指令在多个 CPU 上同时执行
假如你和你的朋友一起做饭。如果你有一个大的厨房,并且你们每个人都有自己的炉灶(相当于每个炉灶是一个CPU),那么你们可以同时做不同的菜。比如说,你炒菜的同时,你的朋友在煮汤,你们是真正地同时进行工作。这就是“并行”。
  • 并发:在同一时刻,有多个指令在单个 CPU 上交替执行

假设只有一个炉灶(一个CPU),但是你们还是想一起完成做饭的任务。那么你们可以通过轮流使用这个炉灶来实现。比如,你先用炉灶炒一分钟的菜,然后让给你的朋友煮一分钟的汤,然后再轮到你炒菜,如此循环。虽然看起来你们是在同时进行,但实际上只是快速地切换任务。这就是“并发”。

同步与异步

  • 需要等待结果返回,才能继续运行就是同步
假如你要去邮局寄信。如果是同步的方式,你就得站在邮局柜台前等工作人员处理完你的信件,拿到收据后,你才能离开去做其他的事情。这段时间里,你什么都不能做,只能等着。
  • 不需要等待结果返回,就能继续运行就是异步

如果改成异步的方式,你可以把信交给邮局的工作人员后,留下你的联系方式就离开了,去做自己的事情。邮局处理完后会通过电话或者短信通知你。这样,你就不需要一直在邮局等着,而是可以利用等待的时间做其他的事情。

线程进程对比

  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集

  • 进程拥有共享的资源,如内存空间等,供其内部的线程共享

  • 进程间通信较为复杂

一、什么是 IPC?

IPC = 进程间通信(Inter-process communication)
就是:同一台电脑上,两个进程之间怎么“聊天”或“传东西,

比如:微信进程想把一条消息传给语音助手进程,它们就得通过某种“通信方式”来完成。

二、常见的进程通信方式

1. 信号量 — 相当于计数器or许可证

想象你和几个同事共用一个停车场,停车场只有2个停车位。门口有个牌子写着:“当前可用车位:2”。每进去一个人,计数就减1;出来一个,计数就加1。如果计数变成0了,后面的人就得排队等着。

作用:协调多个进程对共享资源的访问,保证安全和同步。

2. 共享存储 — 相当于公共公告栏

多个进程可以一起看、一起写的“一块内存区域”。就像公司墙上贴了个公告栏,所有人都能看到上面的信息,也能上去贴新通知。但问题来了:如果两个人同时去改同一个信息,就会乱。所以,必须配合“信号量”来管理,谁要用这块内存,先拿“许可证”(信号量),用完再还回去。

优点:速度快,因为直接读写内存。

缺点:容易冲突,必须靠信号量来协调。

3. 管道通信 — 相当于“单向传话筒”或“流水线传送带”

  • 管道就像一根水管,一头写数据(写进程),另一头读数据(读进程)。
  • 同一时间只能一个人写、一个人读,不能两边同时写。
  • 而且只能单向传数据,比如A→B,不能反过来(叫“半双工”)。

分两种:

  • 匿名管道(Pipes)

    • 就像父子之间传纸条,只能在有“血缘关系”的进程之间用,比如父进程创建子进程后,它们之间可以建立一个临时管道。
    • 这种管道是临时的,关机就没了,存在内存里。
  • 命名管道(Named Pipes)

    • 就像在系统里创建了一个“虚拟文件”,名字叫 my_pipe
    • 任何进程只要知道这个名字,就可以打开它来读或写。
    • 它遵循 FIFO(先进先出),就像排队,先进来的消息先被读走。
    • 虽然看起来像文件,但它其实不存硬盘,只是个通信通道。

4. 消息队列 — 相当于“带标签的快递柜”

消息队列是操作系统内核里的一块区域,用来存“消息”(比如一段数据、指令等)。它像个快递柜,A进程把消息“存进去”,B进程从里面“取出来”。

那比管道强在哪?

对比项管道消息队列
存在哪?匿名管道在内存,命名管道像文件存在内核里,更安全
能不能选着收?不行,只能按顺序收第一条可以! 比如只收“类型=通知”的消息
数据会不会丢?关机就没了除非系统重启或手动删,否则一直存在
通信方向?一般是单向可以实现全双工(双向都能发)

所以消息队列更灵活、更强大。

三、不同电脑之间的进程通信?

如果两个进程不在同一台电脑上,比如你手机上的微信和腾讯服务器上的后台程序通信,那就得靠网络了。

套接字(Socket)——相当于“跨城市电话线”

  • Socket 是一种通信机制,不仅能用于同一台电脑的进程通信,更常用于不同机器之间的通信
  • 就像打电话,你要知道对方的“电话号码”(IP地址 + 端口号),然后拨通,建立连接,就可以传数据了。
  • 常见的 HTTP、TCP、UDP 都是基于 Socket 实现的。

所以:Socket 是网络通信的基石,跨机器通信基本都靠它。

四、线程通信为啥简单?

因为线程都在同一个“王国”(进程)里,共享同一片内存。

举个例子:

  • 多个线程就像同一个公司里的员工,他们都能访问公司的“共享白板”(比如一个全局变量)。
  • A线程写了个数据到白板上,B线程马上就能看到。
  • 所以线程之间通信,很多时候只要读写同一个变量就行,不需要搞什么管道、消息队列那么复杂。

当然,也得注意“抢着写”的问题(用锁、信号量等解决),但总体比进程通信简单多了。

五、Java 中的线程通信方式

  • volatile:让某个变量“实时可见”,一个线程改了,其他线程立刻知道。
  • 等待/通知机制:线程A说“我等会儿”,然后等着;线程B处理完后说“好了,你继续吧”,然后叫醒A。
  • join:线程A说:“你先干完活,我再继续。”
  • InheritableThreadLocal:父线程传数据给子线程,像“家传信物”。
  • MappedByteBuffer:多个线程可以共享一块内存区域(比如文件映射),实现高效通信。

六、最后补一句:线程更轻量,上下文切换成本低

  • 上下文切换:就是CPU从一个任务切换到另一个任务时,要保存当前状态、加载新状态的过程。
  • 进程切换:要保存整个“王国”的地图、资源、权限……很重,花时间。
  • 线程切换:只是同一个王国里的“员工”换班,共享的地盘不用变,只换个人信息,轻得多,快得多

所以现代程序都喜欢用“多线程”而不是“多进程”来提高效率。

线程

创建线程

方法一:Thread

1. 继承Thread类

首先,我们看看怎么通过继承Thread类来创建线程。这种方式比较简单直接,但有一个小缺点是你的类不能再继承其他类了(因为Java不支持多继承,只支持单线程)。

public class ThreadDemo {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start(); // 启动线程
        
        for(int i = 0; i < 100; i++){
            System.out.println("main线程" + i);
        }
    }
}

class MyThread extends Thread {
    @Override
    public void run() {
        for(int i = 0; i < 100; i++) {
            System.out.println("子线程输出:" + i);
        }
    }
}

在这个例子中,我们定义了一个MyThread类,它继承自Thread,并重写了run()方法。然后在main方法里,我们创建了MyThread的一个实例,并调用了它的start()方法。记住,一定要调用start()而不是直接调用run(),否则就不会开启新线程了,而是作为普通的方法调用执行。

2. 匿名内部类方式

public class ThreadDemo {
    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i = 0; i < 100; i++) {
                    System.out.println("匿名内部类子线程输出:" + i);
                }
            }
        });
        thread.start();

        for(int i = 0; i < 100; i++){
            System.out.println("main线程" + i);
        }
    }
}

有时候你可能不想专门去创建一个类来继承Thread,这时可以使用匿名内部类的方式更简洁地创建线程。这里,我们没有创建一个新的类,而是在需要的地方直接创建了一个新的Thread实例,并通过匿名内部类的方式实现了Runnable接口的run()方法。这样做的好处是可以避免单继承带来的局限性。

关于start()方法:当你调用start()方法时,实际上是告诉操作系统(或更准确地说是JVM),你想要启动一个新的线程,并且这个线程应该开始执行run()方法里的代码。如果直接调用run(),那么这段代码就会在当前线程中顺序执行,失去了多线程的意义。

那么问题来了,既然是并发执行的,那为什么不是交替输出?

简单来说:确实是并发执行的,也确实是交替输出了,只是“交替”的粒度不是我们想象中的“一行一行严格交替”。虽然两个线程是并发执行的,但它们的输出并不是像“你一句、我一句”那样精确交替,原因如下:

  • CPU 调度器决定哪个线程在什么时候运行,运行多久。
  • 每个线程一旦被调度,可能会连续执行多条语句(比如连续打印好几行),然后才被“暂停”,让出 CPU 给另一个线程。
  • 这个过程叫做 时间片轮转(time-slicing)

System.out.println() 虽然是线程安全的,但不是“原子块”。每一次 println 是独立的,线程可以连续执行多个 println

那该怎么交替打印线程呢?

可以参考一下:两个线程交替打印数字(六种方法,总有一款适合你)-优快云博客

这里只贴出使用使用wait()notifyAll()方法

public class AlternatePrinting {
    // // 定义锁对象,同步两个线程的操作
    private static final Object lock = new Object();
    private static boolean mainFlag = true; // 控制标志位,true 表示主线程打印,false 表示子线程打印

    public static void main(String[] args) {
        // 创建并启动第一个线程(子线程)
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                // 使用synchronized块确保同一时间只有一个线程可以执行
                synchronized (lock) {
                    while(mainFlag) { // 如果当前轮到主线程打印,则等待
                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    System.out.println("子线程输出:" + i);
                    mainFlag = true; // 改变标志位表示下一次轮到主线程打印
                    lock.notifyAll(); // 通知其他等待的线程
                }
            }
        });
        // 创建并启动第二个线程(主线程的任务)
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10; i++) { 
                // 主线程调用wait()方法等待,直到被notifyAll()唤醒
                synchronized (lock) {
                    while(!mainFlag) { // 如果当前轮到子线程打印,则等待
                        try {
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    System.out.println("main线程" + i);
                    mainFlag = false; // 改变标志位表示下一次轮到子线程打印
                    lock.notifyAll(); // 通知其他等待的线程
                }
            }
        });

        t1.start();
        t2.start();
    }
}

为什么要用synchronized

synchronized 是用来加锁的,防止多个线程同时访问共享资源(比如变量、代码块),避免出现线程安全问题

  • 锁对象 (lock):用于同步两个线程的操作,确保在同一时间只有一个线程能够进入临界区。
  • 标志位 (mainFlag):用来控制哪个线程应该执行。如果mainFlagtrue,则主线程打印;如果为false,则子线程打印。
  • wait():使当前线程等待直到另一个线程调用notify()notifyAll()方法唤醒它。
  • notifyAll():唤醒所有在该对象上调用wait()方法的线程。被唤醒后,线程会重新尝试获取锁,并检查条件是否满足以继续执行。

在你的代码中,有两个线程(t1t2),共享两个东西:1.lock 对象(作为锁)2.mainFlag 变量(控制谁该打印);如果没有 synchronized,可能会发生以下问题:

问题说明
竞态条件两个线程可能同时读写 mainFlag,导致逻辑混乱
可见性问题一个线程修改了 mainFlag,另一个线程可能“看不到”这个变化
wait() 和 notify() 必须在同步块中使用否则会抛出 IllegalMonitorStateException 异常

synchronized(lock) 的作用是什么?

synchronized (lock) {
    // 这里面的代码,同一时间只能有一个线程执行
    // 其他线程必须等当前线程执行完并释放锁后才能进入
}

一个线程进入 synchronized 块时,它会获得 lock 对象的锁。其他线程想进入这个块,就必须等待,直到锁被释放。这样就能保证:

  • mainFlag 的读写是原子的
  • wait() 和 notifyAll() 能正常工作
  • 两个线程不会“撞车”

Thread t1 = new Thread(() -> { ... }) 这是什么写法?有什么用?

这是 Lambda 表达式,是 Java 8 引入的简化写法,用来创建匿名内部类,尤其是实现函数式接口。我们先看传统写法(Java 8 之前):

Thread t1 = new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("我是老写法");
    }
});

Lambda 写法简化了它:

Thread t1 = new Thread(() -> {
    System.out.println("我是新写法");
});
  • () -> { ... } 就是 Lambda 表达式
  • () 表示 run() 方法没有参数
  • { ... } 是 run() 方法的实现体

继承 Thread 类的优缺点

  • 优点:编码简单
  • 缺点:线程类已经继承了 Thread 类无法继承其他类了,功能不能通过继承拓展(单继承的局限性)

方法二:Runnable

什么是Runnable?

你可以把 Runnable 想象成一个“任务说明书”。

  • 它不是线程本身,而是一个要做的事情
  • 比如:你要打印 10 行话、下载一个文件、计算一堆数据……这些都可以写在 Runnable 里。
  • 然后你把这个“任务说明书”交给一个“工人”(也就是 Thread 线程),让他去执行。

所以:线程(Thread)是执行者,Runnable 是任务内容。

怎么用 Runnable 创建线程?两种方式

方式1:先写一个类,实现 Runnable 接口

// 第一步:写一个“任务说明书”
public class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 这里就是你要干的事
        for (int i = 0; i < 3; i++) {
            System.out.println(Thread.currentThread().getName() + " 正在打印: " + i);
        }
    }
}

// 第二步:在 main 方法里,把任务交给线程去执行
public class ThreadDemo {
    public static void main(String[] args) {
        // 创建任务对象
        Runnable task = new MyRunnable();

        // 创建线程,并把任务交给它
        Thread t1 = new Thread(task, "1号线程");
        Thread t2 = new Thread(task, "2号线程");

        // 启动线程(工人开始干活)
        t1.start(); // 1号线程开始执行 run() 里的代码
        t2.start(); // 2号线程也开始执行同样的代码
    }
}

输出结果如下:

方式2:匿名内部类方式(更常用、更简洁)

有时候你懒得专门写一个类,就可以直接“当场写任务内容”。匿名内部类就是:不给类起名字,当场实现 Runnable 接口,写 run() 方法。匿名 new Runnable() 可被替换为 lambda :

public class ThreadDemo2 {
    public static void main(String[] args) {
        // 创建任务 + 创建线程 一步到位
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                System.out.println("t1 线程打印: " + i);
            }
        }, "t1");

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 3; i++) {
                System.out.println("t2 线程打印: " + i);
            }
        }, "t2");

        t1.start();
        t2.start();
    }
}

Thread 构造器的两种形式

public Thread(Runnable target)
public Thread(Runnable target, String name)
  • target:就是你的“任务说明书”(Runnable 对象)
  • name:是你给这个线程起的名字,方便调试(比如叫“下载线程”)

为什么 Thread 类也实现了 Runnable?

public class Thread implements Runnable {
    private Runnable target;

    public void run() {
        if (target != null) {
            target.run();  // 调用你传进来的 Runnable 的 run 方法
        } else {
            // 如果没传 target,就执行自己重写的 run() —— 这是你继承 Thread 时干的事
        }
    }
}

这就像:

  • Thread 自己既是“工人”,也能“接任务”。
  • 如果你传了 Runnable(任务),它就帮你执行那个任务。
  • 如果你没传任务,而是自己继承 Thread 并重写 run(),那它就执行自己的 run()

所以:Thread 是“工人”,Runnable 是“工作内容”,它俩配合干活。

Runnable 方式的优缺点(为什么推荐用它?)

优点解释举例
避免单继承局限Java 类只能继承一个父类,但可以实现多个接口你的类可以继承 Animal,同时实现 Runnable 去当线程任务
可以共享任务多个线程可以共用同一个任务对象10 个线程一起抢一个“抢票任务”,共享票数
解耦任务代码和线程分开,更清晰任务是“打印”,线程是“工人”,换工人不影响任务
适合线程池线程池最喜欢 Runnable 或 Callable把一堆任务丢进线程池,让池子安排线程执行

缺点:写法比直接继承 Thread 多一步(要 new Thread 包装),代码略复杂一点点。

简化:Lambda 表达式

既然 Runnable 只有一个方法(run()),那就可以用 Lambda 简化:

// 以前的匿名内部类
Thread t1 = new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("hello");
    }
});

// 现在的 Lambda 写法(一行搞定)
Thread t2 = new Thread(() -> System.out.println("hello"), "t2");

方法三:Callable

Callable 是什么?

如果说 Runnable 是一个“任务说明书”,那 Callable 就是一个可以返回结果的任务说明书。它和 Runnable 的主要区别在于:

  • Runnable 的 run() 方法没有返回值。
  • Callable 的 call() 方法有返回值,并且还可以抛出异常(Exception)。

如何使用 Callable?

1. 定义 Callable 实现类

首先,你需要定义一个实现了 Callable 接口的类,并重写它的 call() 方法。这个方法就是你的任务内容,并且它可以返回一个结果。

public class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        // 这里是你要执行的任务
        return Thread.currentThread().getName() + "->" + "Hello World";
    }
}

这里我们定义了一个 MyCallable 类,它会返回一个字符串类型的结果。

2. 创建 Callable 对象

接下来,我们需要创建这个 Callable 实现类的对象。

Callable<String> call = new MyCallable();

3. 包装成 FutureTask

由于 Thread 类只能接受 Runnable 类型的任务,所以我们需要把 Callable 包装成 FutureTask,而 FutureTask 实现了 Runnable 接口。

FutureTask<String> task = new FutureTask<>(call);

4. 创建并启动线程

然后,我们可以把这个 FutureTask 对象传给 Thread 来创建一个线程,并启动这个线程。

Thread t = new Thread(task);
t.start();

5. 获取执行结果

最后,通过 task.get() 方法获取 Callable 执行后的返回值。注意,get() 方法是同步的,也就是说如果任务还没执行完,调用 get() 时会阻塞等待直到任务完成。

try {
    String s = task.get(); // 获取call方法返回的结果
    System.out.println(s);
} catch (Exception e) {
    e.printStackTrace();
}

完整代码:

public class ThreadDemo {
    public static void main(String[] args) {
        Callable<String> call = new MyCallable();
        FutureTask<String> task = new FutureTask<>(call);
        Thread t = new Thread(task);
        t.start();

        try {
            String s = task.get(); // 获取call方法返回的结果
            System.out.println(s); // 输出结果
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

class MyCallable implements Callable<String> {
    @Override
    public String call() throws Exception {
        return Thread.currentThread().getName() + "->" + "Hello World";
    }
}

Callable 的优缺点

优点:

  • 可以得到执行结果:这是最大的优势,特别是在你希望知道任务是否成功完成或者想获取计算结果时非常有用。
  • 支持异常处理:可以通过 call() 方法抛出异常,便于错误处理。

缺点:

  • 编码稍微复杂一点:相比 Runnable,多了包装成 FutureTask 的步骤,代码看起来稍微复杂一些。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值