Java并发编程:一

进程与线程

1. 进程与线程

进程

  • 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO
  • 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
  • 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器等),也有的程序只能启动一个实例进程(例如网易云音乐、360 安全卫士等)

线程

  • 一个进程之内可以分为一到多个线程。
  • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行
  • Java 中,线程作为最小调度单位,进程作为资源分配的最小单位。 在 windows 中进程是不活动的,只是作为线程的容器

对比

  • 进程基本上相互独立的,而线程存在于进程内,是进程的一个子集
  • 进程拥有共享的资源,如内存空间等,供其内部的线程共享
  • 进程间通信较为复杂
    • 同一台计算机的进程通信称为 IPCInter-process communication
    • 不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP
  • 线程通信相对简单,因为它们共享进程内的内存,一个例子是多个线程可以访问同一个共享变量
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低

2. 并发与并行

并发(concurrent)

并发是指单核CPU轮流处理多个任务

单核 cpu 下,线程实际还是 串行执行 的。操作系统中有一个组件叫做任务调度器,将 cpu 的时间片( windows下时间片最小约为 15 毫秒)分给不同的程序使用

并行(parallelism)

并行是指,多核CPU同时进行多个任务的处理

应用-异步调用

以调用方角度来讲,如果
  • 需要等待结果返回,才能继续运行就是同步
  • 不需要等待结果返回,就能继续运行就是异步

1)设计

多线程可以让同步方法变为异步执行,这样可以节约时间,例如从磁盘中读取文件,这段时间里CPU只能等待,干不了其他事。如果转为异步执行,让另外一个线程去读取文件,那么这段时间里主线程便能利用CPU继续执行之后的代码

2)结论

  • 比如在项目中,视频文件需要转换格式等操作比较费时,这时开一个新线程处理视频转换,避免阻塞主线程
  • tomcat 的异步 servlet 也是类似的目的,让用户线程处理耗时较长的操作,避免阻塞 tomcat 的工作线程
  • ui 程序中,开线程进行其他操作,避免阻塞 ui 线程

应用-效率提升

充分利用多核 cpu 的优势,可以提高运行效率。
例如以下场景:
计算 1 花费 10 ms
计算 2 花费 11 ms
计算 3 花费 9 ms
汇总需要 1 ms
  • 如果是串行执行,即依次执行这四个任务,那么需要的总时间是:10+11+9+1=31ms
  • 如果是多核CPU,同时开启四个线程进行计算,那么需要的总时间可以大大缩短为:11+1=12ms

如果是单核CPU,那么即使开启多个线程,依然是轮流执行。

  1. 单核 cpu 下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使用cpu ,不至于一个线程总占用 cpu,别的线程没法干活
  2.  多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的
    1. 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任务都能拆分(参考后文的【阿姆达尔定律】)
    2. 也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义
  3. IO 操作不占用 cpu,只是我们一般拷贝文件使用的是【阻塞 IO】,这时相当于线程虽然不用 cpu,但需要一直等待 IO 结束,没能充分利用线程。所以才有后面的【非阻塞 IO】和【异步 IO】优化


Java线程

1. 创建和运行线程

1.1 直接使用Thread

以匿名内部类形式创建线程类

// 创建线程对象
Thread t = new Thread() {
    public void run() {
        // 要执行的任务
    }
};
// 启动线程
t.start();
  • start() 方法底层其实是给 CPU 注册当前线程,并且触发 run() 方法执行
  • 线程的启动必须调用 start() 方法,如果线程直接调用 run() 方法,相当于变成了普通类的执行,此时主线程将只有执行该线程
  • 建议线程先创建子线程,主线程的任务放在之后,否则主线程(main)永远是先执行完

1.2 使用Runnable配合Thread

把【线程】和【任务】(要执行的代码)分开
  • Thread 代表线程
  • Runnable 可运行的任务(线程要执行的代码)
Runnable runnable = new Runnable() {
    public void run(){
        // 要执行的任务
    }
};
// 创建线程对象
Thread t = new Thread( runnable );
// 启动线程
t.start();

还可以使用lambda表达式精简代码

Runnable runnable = () -> {
    // 要执行的任务
}
};
// 创建线程对象
Thread t = new Thread( runnable );
// 启动线程
t.start();

1.3 Thread类与Runnable接口

  • 方法 1 是把线程和任务合并在了一起,方法 2 是把线程和任务分开了
  • Thread 是表示线程的类,继承自 java.lang.Thread,可以直接创建线程。
  • Runnable 是一个接口,定义了线程要执行的任务,需要通过实现接口的类来实现任务,然后传递给 Thread 使用。这种方式更加灵活,允许多个线程共享同一个任务
  • Runnable 更容易与线程池等高级 API 配合
  • Runnable 让任务类脱离了 Thread 继承体系,更灵活

1.4 FutureTask配合Thread

FutureTask与Runnable类似,好处是可以接收返回值

// 创建任务对象
FutureTask<Integer> task3 = new FutureTask<>(() -> {
     log.debug("hello");
     return 100;
});
// 参数1 是任务对象; 参数2 是线程名字,推荐
new Thread(task3, "t3").start();
// 主线程阻塞,同步等待 task 执行完毕的结果
Integer result = task3.get();
log.debug("结果是:{}", result);


2. 观察多个线程同时运行

@Slf4j(topic = "c.t2")
public class t2 {
    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            while (true)
                log.debug("11111");
        },"t1");

        Thread t2 = new Thread(() -> {
            while (true)
                log.debug("2222222222");
        },"t2");

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

 可以看到,同时开启多个线程时:

  • 多个线程交替执行
  • 无法由程序员控制谁先谁后

3. 查看进程的方法

Windows

  • 任务管理器可以查看进程和线程数,也可以用来杀死进程
  • tasklist 查看进程
  • taskkill 杀死进程

Linux

  • ps -fe 查看所有进程
  • ps -fT -p <PID> 查看某个进程(PID)的所有线程
  • kill 杀死进程
  • top 按大写 H 切换是否显示线程
  • top -H -p <PID> 查看某个进程(PID)的所有线程

Java

  • jps 命令查看所有 Java 进程
  • jstack <PID> 查看某个 Java 进程(PID)的所有线程状态
  • jconsole 来查看某个 Java 进程中线程的运行情况(图形界面)

4. 线程运行原理

每个线程开启后,JVM就会为其分配一块栈内存。

  • 一个栈由多个栈帧组成,每个栈帧都对应一个方法。活动栈帧便是当前正在执行的那个方法
    • 方法内的局部变量存储在栈中,如果是对象,则存储在堆中,栈中变量存储的是堆中对象的引用
  • 当一个线程的所有方法都执行完毕时,这个线程就结束了,对应的栈也随之销毁
  • 主线程结束并不代表程序结束,所有线程都执行完毕之后程序结束
    • Java 中 main 方法启动的是一个进程也是一个主线程,main 方法里面的其他线程均为子线程,main 线程是这些线程的父线程

线程上下文切换(Thread Context Switch)

因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码
  • 线程的 cpu 时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自己调用了 sleepyieldwaitjoinparksynchronizedlock 等方法
Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态, Java 中对应的概念就是程序计数器(Program Counter Register ),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的
  • 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
  • Context Switch 频繁发生会影响性能

5. 常用方法


6. start与run

调用run()方法

@Slf4j(topic = "c.thread")
public class t1 {
    public static void main(String[] args) {
        Thread t = new Thread("t1"){
            @Override
            public void run() {
                log.debug("run...");
            }
        };
        t.run();
        log.debug("running...");
    }
}

结果

12:04:07 [main] c.thread - run...
12:04:07 [main] c.thread - running...

调用start()方法

@Slf4j(topic = "c.thread")
public class t1 {
    public static void main(String[] args) {
        Thread t = new Thread("t1"){
            @Override
            public void run() {
                log.debug("run...");
            }
        };
        t.start();
        log.debug("running...");
    }
}

结果

12:05:36 [main] c.thread - running...
12:05:36 [t1] c.thread - run...

可以看到,调用run()方法并不会开启新线程,而是会直接在当前线程执行方法。

而调用start()方法才会启动新的线程


7. sleep与yield

sleep

1. 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)
public class sleep {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread("t1"){
            @Override
            public void run() {
                log.debug("sleep...");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                log.debug("sleep...");
            }
        };
        System.out.println(t.getState());
        t.start();
        System.out.println(t.getState());
        Thread.sleep(500);
        System.out.println(t.getState());
    }
}
NEW
RUNNABLE
12:21:24 [t1] c.sleep - sleep...
TIMED_WAITING
12:21:26 [t1] c.sleep - sleep...

2. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出InterruptedException
3. 睡眠结束后的线程未必会立刻得到执行
4. 建议用 TimeUnit sleep 代替 Thread sleep 来获得更好的可读性
TimeUnit.SECONDS.sleep(1);
Thread.sleep(1000);

sleep常用于以下场景:

  1. 模拟等待: 通常在需要让线程等待一段时间后再继续执行的情况下使用 sleep 方法。例如,在多线程编程中,可能需要等待某个资源就绪或某个条件满足,此时可以使用 sleep 方法来暂停线程的执行。
  2. 定时任务: sleep 方法可以用于实现定时任务,例如在某个时间点执行特定的操作。
  3. 控制速率: 有时需要限制某个操作的执行速率,可以使用 sleep 方法来控制每次执行之间的时间间隔。

yield

  • 调用 yield 会让当前线程从 Running 进入 Runnable 就绪状态,然后调度执行其它线程
  • 具体的实现依赖于操作系统的任务调度器

yield 方法的常见场景:

  1. 多线程协作: 当有多个线程需要协作执行,且需要提高线程之间的公平性时,可以使用 yield 方法来提示调度器让出执行权,给其他线程机会。
  2. 避免忙等待: 有些情况下,线程可能会出现忙等待的情况,通过在循环中使用 yield 可以减轻这种忙等待的问题。

线程优先级

  • 线程优先级会提示(hint)调度器优先调度该线程,但它仅仅是一个提示,调度器可以忽略它
  • 如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 闲时,优先级几乎没作用
public class test1 {
    public static void main(String[] args) {
        Runnable task1 = () -> {
            int count = 0;
            for (; ; ) {
                System.out.println("---->1 " + count++);
            }
        };
        Runnable task2 = () -> {
            int count = 0;
            for (; ; ) {
                //Thread.yield();
                System.out.println("      ---->2 " + count++);
            }
        };
        Thread t1 = new Thread(task1, "t1");
        Thread t2 = new Thread(task2, "t2");
        t1.setPriority(Thread.MIN_PRIORITY);
        t2.setPriority(Thread.MAX_PRIORITY);
        t1.start();
        t2.start();
    }
}

例如上面这个程序。t1的优先级最低,而t2的优先级最高,理论上来说,t2的运行次数应该比t1多得多,但实际结果却并非如此


sleep-应用

在没有利用 cpu 来计算时,不要让 while(true) 空转浪费 cpu ,这时可以使用 yield sleep 来让出 cpu 的使用权给其他程序
while(true) {
    try {
        Thread.sleep(50);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}
  • 可以用 wait 或 条件变量达到类似的效果
  • 不同的是,后两种都需要加锁,并且需要相应的唤醒操作,一般适用于要进行同步的场景
  • sleep 适用于无需锁同步的场景

8. join

以下这段代码,r 的最终结果是什么?

static int r = 0;
public static void main(String[] args) throws InterruptedException {
    test1();
}
private static void test1() throws InterruptedException {
     log.debug("开始");
     Thread t1 = new Thread(() -> {
         log.debug("开始");
         Thread.sleep(1000);
         log.debug("结束");
         r = 10;
     },"t1");
 t1.start();
 log.debug("结果为:{}", r);
 log.debug("结束");
}
14:14:05 [main] c.t - 开始
14:14:06 [t1] c.t - 开始
14:14:06 [main] c.t - 结果为:0
14:14:06 [main] c.t - 结束
14:14:07 [t1] c.t - 结束

原因:main 线程与 t1 线程同时运行,但是 t1 线程却陷入了1s的休眠,而此时 main 线程还在继续运行,因此当 main 线程输出结果时, t1 线程还未执行完毕,所以结果是 0

解决办法:使用 join()方法

t1.start();
t1.join();
log.debug("结果为:{}", r);
log.debug("结束");

在一个线程中调用另一个线程的 join 方法,将当前线程阻塞,直到被调用的线程执行完毕。

14:18:23 [main] c.t - 开始
14:18:23 [t1] c.t - 开始
14:18:24 [t1] c.t - 结束
14:18:24 [main] c.t - 结果为:10
14:18:24 [main] c.t - 结束

可以看到,t1 线程结束之后 main线程才继续执行,因此结果为10

同时等待多个线程

如果是同时等待多个线程结束,那么在多线程环境下,总等待时长仅取决于需要时长最长的那个线程

如下图:t1 线程需要1s,t2 线程须要2s

如果先调用 t1.join(),那么在 t1,t2 线程同时运行的情况下,t1.join 等待完毕后,仅需再等待1s,t2.join也可执行完毕

如果先调用 t2.join,那么 t2.join结束时,t1线程早已结束,t1.join几乎无需耗费时间

 有时效的join

t1.join(等待时间)
  • 如果等待时间结束之后,t1 线程仍未执行完毕,那么便不再等待
  • 反之,如果等待时间还未结束,t1 线程便执行完毕,那么提前结束等待

9. interrupt打断阻塞

interrupt方法可以打断sleep,wait,join的线程

  • 当线程内调用sleep、wait、join等方法的时候,线程被设置中断状态的时候,会抛出异常InterruptedException,同时设置线程状态为false

这几个方法都会让线程进入阻塞的状态

打断标记

在Java中,线程的打断标记(interrupt flag)是一个布尔标志,用于表示线程是否被请求打断。每个线程都有一个与之相关联的打断标记。

当一个线程调用另一个线程的 interrupt() 方法时,会向目标线程发出一个打断请求,目标线程的打断标记会被设置为 true,表示该线程被请求打断。

但是,打断sleep的线程,则会清空打断状态

private static void test1() throws InterruptedException {
 Thread t1 = new Thread(()->{
 sleep(1);
 }, "t1");
 t1.start();
 sleep(0.5);
 t1.interrupt();
 log.debug(" 打断状态: {}", t1.isInterrupted());
}
java.lang.InterruptedException: sleep interrupted
	at java.lang.Thread.sleep(Native Method)
	at thread.test3.lambda$test1$0(test3.java:13)
	at java.lang.Thread.run(Thread.java:750)
15:50:06 [main] c.t -  打断状态: false

此处的打断标记便被重置为了false

而打断正常运行的线程 , 不会清空打断状态

此外,interrupt()方法并不会真正地打断一个线程的运行,相当于它只是给线程发送一个中断提示,但是否真正中断还是取决于线程本身是否响应这个中断提示

private static void test2() throws InterruptedException {
    Thread t2 = new Thread(()->{
        while(true) {
            Thread current = Thread.currentThread();
            boolean interrupted = current.isInterrupted();
            if(interrupted) {
                log.debug(" 打断状态: {}", interrupted);
                break;
            }
        }
    }, "t2");
t2.start();
sleep(0.5);
t2.interrupt();
}

例如以上程序,如果去除 if 语句,那么 t2.interrupt()方法执行后,while(true)循环也不会结束。

即使处于sleep状态的进程被打断,抛出异常之后也可以继续执行。

interrupt打断park()

private static void test3() throws InterruptedException {
     Thread t1 = new Thread(() -> {
         log.debug("park...");
         LockSupport.park();
         log.debug("unpark...");
         log.debug("打断状态:{}", Thread.currentThread().isInterrupted());
     }, "t1");
 t1.start();
 sleep(0.5);
 t1.interrupt();
}

输出

21:11:52.795 [t1] c.TestInterrupt - park... 
21:11:53.295 [t1] c.TestInterrupt - unpark... 
21:11:53.295 [t1] c.TestInterrupt - 打断状态:true

要注意的是,当打断标记为true时,即使再次调用park()方法也无法暂停线程

可以使用Thread.interrupt()方法清楚打断状态


10. 不推荐的方法

有一些不推荐使用的方法,这些方法已过时,容易破坏同步代码块,造成线程死锁

11. 主线程与守护线程

默认情况下, Java 进程需要等待所有线程都运行结束,才会结束。有一种特殊的线程叫做守护线程,只要其它非守护线程运行结束了,即使守护线程的代码没有执行完,也会强制结束。
// 设置该线程为守护线程
thread.setDaemon(true);
注意
  • 垃圾回收器线程就是一种守护线程
  • Tomcat 中的 Acceptor Poller 线程都是守护线程,所以 Tomcat 接收到 shutdown 命令后,不会等待它们处理完当前请求

12. 五种状态

从操作系统的层面来说,线程状态一共有五种

  • 【初始状态】仅是在语言层面创建了线程对象,还未与操作系统线程关联
  • 【可运行状态】(就绪状态)指该线程已经被创建(与操作系统线程关联),可以由 CPU 调度执行,只是当前仍在等待CPU分配时间片
  • 【运行状态】指获取了 CPU 时间片运行中的状态
    • CPU 时间片用完,会从【运行状态】转换至【可运行状态】,会导致线程的上下文切换
  • 【阻塞状态】
    • 如果调用了阻塞 API,如 BIO 读写文件,这时该线程实际不会用到 CPU,会导致线程上下文切换,进入【阻塞状态】
    • BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
    • 与【可运行状态】的区别是,对【阻塞状态】的线程来说只要它们一直不唤醒,调度器就一直不会考虑调度它们
  • 【终止状态】表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态

13. 六种状态

从Java的层面来说,线程状态一共有六种

  • NEW 线程刚被创建,但是还没有调用 start() 方法
  • RUNNABLE 当调用了 start() 方法之后,注意,Java API 层面的 RUNNABLE 状态涵盖了 操作系统 层面的
  • 【可运行状态】、【运行状态】和【阻塞状态】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行)
  • BLOCKED WAITING TIMED_WAITING 都是 Java API 层面对【阻塞状态】的细分,后面会在状态转换一节详述
    • 阻塞状态(Blocked): 线程在某些条件下会进入阻塞状态,例如等待I/O操作完成、等待获取锁、等待其他线程执行完毕等。在阻塞状态下,线程不会消耗CPU时间,直到满足特定条件才会重新进入就绪状态。

    • 等待状态(Waiting): 线程通过调用 Object.wait()Thread.join()LockSupport.park() 等方法进入等待状态,等待某个条件的满足或者等待其他线程的通知。

    • 超时等待状态(Timed Waiting): 类似等待状态,但可以设置等待的超时时间,线程会在超时时间到达或者条件满足时重新进入就绪状态。例如,通过 Thread.sleep()Object.wait(long)Thread.join(long) 等方法可以使线程进入超时等待状态。

  • TERMINATED 当线程代码运行结束

共享模型之管程

1. 共享带来的问题

static int counter = 0;
public static void main(String[] args) throws InterruptedException {
     Thread t1 = new Thread(() -> {
         for (int i = 0; i < 5000; i++) {
             counter++;
         }
     }, "t1");

     Thread t2 = new Thread(() -> {
         for (int i = 0; i < 5000; i++) {
             counter--;
         }
     }, "t2");

     t1.start();
     t2.start();
     t1.join();
     t2.join();
     log.debug("{}",counter);
}

以上这段代码在运行时会出现一个问题:自增、自减操作在交替运行时,由于时间片结束而线程上下文切换,最终导致结果不确定。

临界区(Critical Section)

  • 一个程序运行多个线程本身是没有问题的
  • 问题出在多个线程访问共享资源
    • 多个线程读共享资源其实也没有问题
    • 在多个线程对共享资源读写操作时发生指令交错,就会出现问题
  • 一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
static int counter = 0;
static void increment() 
// 临界区
{ 
     counter++;
}
static void decrement() 
// 临界区
{ 
     counter--;
}

竞态条件(Race Condition)

多个线程在临界区内执行,由于代码的 执行序列不同 而导致结果无法预测,称之为发生了 竞态条件

2. synchronized

为了避免临界区的竞态条件发生,有多种手段可以达到目的。
  • 阻塞式的解决方案:synchronizedLock
  • 非阻塞式的解决方案:原子变量

这里采用的解决办法是使用 synchronized 关键字,即俗称的对象锁。对象锁会采用互斥的方式让同一时刻至多只有一个线程能持有对象锁,其他线程再想获得这个对象锁时会阻塞陷入等待。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。

注意
虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:
  • 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
  • 同步是由于线程执行的先后、顺序不同、需要一个线程等待其它线程运行到某个点

 使用 synchronized 关键字对原代码进行改进

static int counter = 0;
static final Object room = new Object();
public static void main(String[] args) throws InterruptedException {
     Thread t1 = new Thread(() -> {
         for (int i = 0; i < 5000; i++) {
             synchronized (room) {
                 counter++;
             }
         }
     }, "t1");

     Thread t2 = new Thread(() -> {
         for (int i = 0; i < 5000; i++) {
             synchronized (room) {
                 counter--;
             }
         }
     }, "t2");

     t1.start();
     t2.start();
     t1.join();
     t2.join();
     log.debug("{}",counter);
}

注意事项:

即使线程1获得了对象锁,也并不意味着线程1能够一直运行下去。当CPU时间片结束时,即使线程1还未执行完代码,依然会让出CPU,但是其他线程还是拿不到这个对象锁。当线程1再次分到时间片,执行完代码释放对象锁之后,其他线程才有机会竞争对象锁。

思考

  • 如果把 synchronized(obj) 放在 for 循环的外面,如何理解?-- 原子性
    • 会等到5000次自增都执行完毕之后才会释放锁
  • 如果 t1 synchronized(obj1) t2 synchronized(obj2) 会怎样运作?-- 锁对象
    • 相当于同一个房间有了两把钥匙,起不到保护作用
  • 如果 t1 synchronized(obj) t2 没有加会怎么样?如何理解?-- 锁对象
    • 线程1时间片完后,线程2想进入这个房间时不会被阻塞,不是线程安全的

面向对象改进

class Room {
     int value = 0;
     public void increment() {
         synchronized (this) {
             value++;
         }
     }
     public void decrement() {
         synchronized (this) {
             value--;
         }
     }
     public int get() {
         synchronized (this) {
             return value;
         }
     }
}

采用实例调用方法时,锁住的就是实例对象。这里 this 和【实例对象】都是同一个。


3. 方法上的 synchronized

class Test{
     public synchronized void test() {
     
     }
}

等价于

class Test{
 public void test() {
     synchronized(this) {
 
     }
 }
}
class Test{
     public synchronized static void test() {
     }
}

等价于

class Test{
     public static void test() {
         synchronized(Test.class) {
 
         }
     }
}
  • 加 static:锁住的是 类对象
  • 不加static:锁住的是 实例对象
  • 类对象 和 实例对象 并不同,不能理解为同一把锁

4. 变量的线程安全分析

成员变量和静态变量是否线程安全?

  • 如果它们没有共享,则线程安全
  • 如果它们被共享了,根据它们的状态是否能够改变,又分两种情况
    • 如果只有读操作,则线程安全
    • 如果有读写操作,则这段代码是临界区,需要考虑线程安全

局部变量是否线程安全?

  • 局部变量是线程安全的
  • 但局部变量引用的对象未必
    • 如果引用对象没有逃离方法的作用范围,那么是线程安全的
    • 如果逃离了方法的作用范围,那么就不是线程安全的

局部变量线程安全分析

public static void test1() {
     int i = 10;
     i++;
}

每个线程在调用test1()方法时,都会在自己的栈内生成一个栈帧,局部变量 i 存放在栈帧的局部变量表中,没有线程安全问题

而局部变量的引用则有些不同

class ThreadUnsafe {
     ArrayList<String> list = new ArrayList<>();
     public void method1(int loopNumber) {
         for (int i = 0; i < loopNumber; i++) {
             // { 临界区, 会产生竞态条件
             method2();
             method3();
             // } 临界区
         }
     }

     private void method2() {
         list.add("1");
     }

     private void method3() {
         list.remove(0);
     }
}

执行

static final int THREAD_NUMBER = 2;
static final int LOOP_NUMBER = 200;
public static void main(String[] args) {
     ThreadUnsafe test = new ThreadUnsafe();
         for (int i = 0; i < THREAD_NUMBER; i++) {
             new Thread(() -> {
                 test.method1(LOOP_NUMBER);
             }, "Thread" + i).start();
         }
    }

由于method2与method3方法没有使用 synchronized 关键字,无法保证方法内操作的原子性,那么便有可能发生以下情形:

  • 线程1执行添加操作,还未写回内存,时间片到,让出CPU
  • 线程2执行添加操作,写回内存,还未执行移除操作,时间片到,让出CPU
  • 线程1继续执行添加操作,写回内存,但是,这里写回内存的位置与线程2写回内存的位置相同,即相当于两次添加操作写入的都是 list[0] 的位置
  • 执行两次移除操作,但list中只有一个数,报错

将list修改为局部变量,就不会出现线程安全的问题了

class ThreadSafe {
     public final void method1(int loopNumber) {
         ArrayList<String> list = new ArrayList<>();
         for (int i = 0; i < loopNumber; i++) {
             method2(list);
             method3(list);
         }
     }

     private void method2(ArrayList<String> list) {
         list.add("1");
     }

     private void method3(ArrayList<String> list) {
         list.remove(0);
     }
}
分析:
  • list 是局部变量,每个线程调用时会创建其不同实例,没有共享
  • method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象
  • method3 的参数分析与 method2 相同


方法访问修饰符带来的思考,如果把 method2 method3 的方法修改为 public 会不会带来线程安全问题?
  • 情况1:有其它线程调用 method2 method3
    • ​​​​​​​不会,此处的 list 是线程私有的,其他线程传递的ArrayList参数与本线程的并不是同一个
  • 情况2:在 情况1 的基础上,为 ThreadSafe 类添加子类,子类覆盖 method2 method3 方法
    • ​​​​​​​会,因为子类调用的 list 参数是同一个,而子类中又新开了一个线程,造成了 list 的逃逸,会导致线程安全问题
class ThreadSafe {
     public final void method1(int loopNumber) {
         ArrayList<String> list = new ArrayList<>();
         for (int i = 0; i < loopNumber; i++) {
             method2(list);
             method3(list);
         }
     }

     private void method2(ArrayList<String> list) {
         list.add("1");
     }
     private void method3(ArrayList<String> list) {
         list.remove(0);
     }
}

class ThreadSafeSubClass extends ThreadSafe{
     @Override
     public void method3(ArrayList<String> list) {
         new Thread(() -> {
             list.remove(0);
         }).start();
     }
}

常见线程安全类

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent 包下的类
多个线程调用它们同一个实例的某个方法时,是线程安全的
  • 它们的每个方法是原子的
  • 注意它们多个方法的组合不是原子的

以下这段代码是否线程安全?

Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
     table.put("key", value);
}

并不安全

  • 线程1判断 get("key") == null 为 true,还未 put 时,时间片到,让出CPU
  • 线程2判断 get("key")== null 为 true,put("key",v2)
  • 线程1得到时间片,继续执行代码,put("key",v1)

于是本应只执行一次的 put 操作就被执行了两次


不可变类线程安全性

String Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的
但是,String replace substring 等方法【可以】改变值,那么这些方法又是如何保证线程安全的呢?
其实这些方法并没有真正改变原字符串的内容。是在将原字符串拷贝了一份的基础进行修改的。


5. 习题

public class ExerciseTransfer {
     public static void main(String[] args) throws InterruptedException {
         Account a = new Account(1000);
         Account b = new Account(1000);
         Thread t1 = new Thread(() -> {
             for (int i = 0; i < 1000; i++) {
                 a.transfer(b, randomAmount());
             }
         }, "t1");

         Thread t2 = new Thread(() -> {
             for (int i = 0; i < 1000; i++) {
                 b.transfer(a, randomAmount());
             }
         }, "t2");

     t1.start();
     t2.start();
     t1.join();
     t2.join();
     // 查看转账2000次后的总金额
     log.debug("total:{}",(a.getMoney() + b.getMoney()));
 }

     // Random 为线程安全
     static Random random = new Random();
     // 随机 1~100
     public static int randomAmount() {
         return random.nextInt(100) +1;
     }
}

class Account {
     private int money;
     public Account(int money) {
         this.money = money;
     }
     public int getMoney() {
         return money;
     }
     public void setMoney(int money) {
         this.money = money;
     }
     public void transfer(Account target, int amount) {
         if (this.money > amount) {
             this.setMoney(this.getMoney() - amount);
             target.setMoney(target.getMoney() + amount);
         }
     }
}

如何解决此处的线程安全问题?

可以想下面这样更改吗?

public synchronized void transfer(Account target, int amount) {
     if (this.money > amount) {
         this.setMoney(this.getMoney() - amount);
         target.setMoney(target.getMoney() + amount);
     }
}

不行。在方法上加锁,锁住的其实是 this ,而在这段代码中,可以看到,同时有 ab 两个对象在调用 transfer 方法。也就是说,这里并没有锁住同一个对象,而是分别锁住了两个不同的对象,这样做是无法保证线程安全的。

可以这样修改: 

    public void transfer(Account target, int amount) {
        synchronized(Account.class) {
            if (this.money > amount) {
                this.setMoney(this.getMoney() - amount);
                target.setMoney(target.getMoney() + amount);
            }
        }
    }

这里不再锁住 this 对象,而是改为了锁住 Account 类对象。这样做的好处就是 a 和 b 调用 transfer方法时,锁住的就是同一个对象,也就是锁能正常生效了,可以保证线程安全。

但是这样也有一个缺点:那就是同一时刻只能有一个线程执行 transfer 方法,如果同时有很多客户进行转账操作,就会导致效率底下。


6. Monitor 概念

Java对象头

以32位虚拟机为例

普通对象

|--------------------------------------------------------------|
| Object Header ( 64 bits )                                     |
|------------------------------------|-------------------------|
| Mark Word ( 32 bits ) | Klass Word ( 32 bits )       |
|------------------------------------|-------------------------|

数组对象

|----------------------------------------------------------------------------------------|
| Object Header ( 96 bits )                                                                    |
|--------------------------------|---------------------------|----------------------------|
| Mark Word ( 32 bits )         | Klass Word ( 32 bits ) | array length ( 32 bits ) |
|--------------------------------|---------------------------|----------------------------|
其中 Mark Word 结构为
|-------------------------------------------------------|------------------------------|
| Mark Word ( 32 bits )                                  |      State                        |
|-------------------------------------------------------|------------------------------|
| hashcode: 25 | age: 4 | biased_lock: 0 | 01 | Normal                        |
|-------------------------------------------------------|------------------------------|
| thread: 23 | epoch: 2 | age: 4 | biased_lock: 1 | 01 | Biased              |
|-------------------------------------------------------|------------------------------|
| ptr_to_lock_record: 30                    | 00     | Lightweight Locked     |
|-------------------------------------------------------|------------------------------|
| ptr_to_heavyweight_monitor: 30 | 10        | Heavyweight Locked |
|-------------------------------------------------------|------------------------------|
|                                                            | 11 | Marked for GC             |
|-------------------------------------------------------|------------------------------|
64 位虚拟机 Mark Word
|--------------------------------------------------------------------|------------------------------|
| Mark Word ( 64 bits )                                                  |      State                        |
|--------------------------------------------------------------------|------------------------------|
| unused: 25 | hashcode: 31 | unused: 1 | age: 4 | biased_lock: 0 | 01 | Normal |
|--------------------------------------------------------------------|------------------------------|
| thread: 54   | epoch: 2          | unused: 1 | age: 4 | biased_lock: 1 | 01 | Biased  |
|--------------------------------------------------------------------|------------------------------|
| ptr_to_lock_record: 62                                        | 00 | Lightweight Locked     |
|--------------------------------------------------------------------|------------------------------|
| ptr_to_heavyweight_monitor: 62                        | 10 | Heavyweight Locked |
|--------------------------------------------------------------------|------------------------------|
|                                                                            | 11 | Marked for GC            |
|--------------------------------------------------------------------|------------------------------|

monitor(锁) 原理

Monitor 被翻译为 监视器 管程
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针
Monitor 结构如下


7. wait/notify

API 介绍

  • obj.wait() 让进入 object 监视器的线程到 waitSet 等待
  • obj.notify() object 上正在 waitSet 等待的线程中挑一个唤醒
  • obj.notifyAll() object 上正在 waitSet 等待的线程全部唤醒

以上三个都属于Object对象的方法,必须要先获得对象锁,才能调用这些方法

  • wait() 方法会释放对象的锁,进入 WaitSet 等待区,从而让其他线程有机会获取对象的锁。调用 wait 的线程会无限制等待,直到notify 为止
  • wait(long n) 有时限的等待, n 毫秒后结束等待,或是被 notify

8. wait/notify 的正确姿势

以下这段代码能否实现预期目标?该如何优化?

new Thread(() -> {
 synchronized (room) {
 log.debug("有烟没?[{}]", hasCigarette);
 if (!hasCigarette) {
 log.debug("没烟,先歇会!");
 sleep(2);
 }
 log.debug("有烟没?[{}]", hasCigarette);
 if (hasCigarette) {
 log.debug("可以开始干活了");
 }
 }
}, "小南").start();
for (int i = 0; i < 5; i++) {
 new Thread(() -> {
 synchronized (room) {
 log.debug("可以开始干活了");
 }
 }, "其它人").start();
}
sleep(1);
new Thread(() -> {
 // 这里能不能加 synchronized (room)?
 hasCigarette = true;
 log.debug("烟到了噢!");
}, "送烟的").start();
  • 其它干活的线程,都要一直阻塞,效率太低
  • 小南线程必须睡足 2s 后才能醒来,就算烟提前送到,也无法立刻醒来
  • 加了 synchronized (room) 后,就好比小南在里面反锁了门睡觉,烟根本没法送进门,main 没加synchronized 就好像 main 线程是翻窗户进来的

解决思路

  • 使用 wait/notify 方法实现等待与唤醒
  • 同时将 if 改为 while 循环,在条件未满足却被唤醒后继续等待
  • 如果同时有多个等待的线程,使用 notifyAll 方法以防虚假唤醒,以及未唤醒应该唤醒的线程
    • 虚假唤醒:假如同时有两个处于 WAITING 状态的线程,调用 notify 方法时,会随机唤醒其中一个线程。如果唤醒的是仍未满足条件的线程,那么就称为虚假唤醒
    • notify:JVM中的规范是随机唤醒,但是 hotspot 底层CPP实现是顺序唤醒,先来先服务

修改后如下:

new Thread(() -> {
     synchronized (room) {
         log.debug("有烟没?[{}]", hasCigarette);
         while (!hasCigarette) {
             log.debug("没烟,先歇会!");
             try {
                 room.wait();
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
        }
         log.debug("有烟没?[{}]", hasCigarette);
         if (hasCigarette) {
             log.debug("可以开始干活了");
         } else {
             log.debug("没干成活...");
         }
     }
}, "小南").start();

new Thread(() -> {
     synchronized (room) {
         Thread thread = Thread.currentThread();
         log.debug("外卖送到没?[{}]", hasTakeout);
         if (!hasTakeout) {
             log.debug("没外卖,先歇会!");
             try {
                 room.wait();
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
         }
         log.debug("外卖送到没?[{}]", hasTakeout);
         if (hasTakeout) {
             log.debug("可以开始干活了");
         } else {
             log.debug("没干成活...");
         }
     }
}, "小女").start();

sleep(1);
new Thread(() -> {
     synchronized (room) {
     hasTakeout = true;
     log.debug("外卖到了噢!");
     room.notifyAll();
 }
}, "送外卖的").start();

9. park & unpark

基本使用

park/unpark都是LockSupport类中的方法

// 暂停当前线程
LockSupport.park(); 
// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象)

特点

与 wait/notify 相比

  • waitnotify notifyAll 必须配合 Object Monitor 一起使用,而 parkunpark 不必
  • park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程,就不那么【精确】,而 unpark 可以准确唤醒一个等待线程
  • park & unpark 可以先 unpark,而 wait & notify 不能先 notify
    • ​​​​​​​park & unpark采用的是信号量机制,如果先 unpark ,那么下一次 park 时就不会进入等待状态

先 unpark 再 park

Thread t1 = new Thread(() -> {
 log.debug("start...");
 sleep(2);
 log.debug("park...");
 LockSupport.park();
 log.debug("resume...");
}, "t1");
t1.start();
sleep(1);
log.debug("unpark...");
LockSupport.unpark(t1);
18:43:50.765 c.TestParkUnpark [t1] - start... 
18:43:51.764 c.TestParkUnpark [main] - unpark... 
18:43:52.769 c.TestParkUnpark [t1] - park... 
18:43:52.769 c.TestParkUnpark [t1] - resume...

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值