复习 - 并发

一、基本概念

1. 进程和线程

进程:进程是程序的一次执行过程。是CPU资源分配的最小单位。每个进程都有自己独立的一块内存空间,一个进程可以有多个线程。
当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。

线程:线程是CPU调度的最小单位,同一个进程下的多个线程共享此进程的全部资源。

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

两者对比

  • 根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位
  • 资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享堆和方法区(1.8 转到直接内存的元空间),每个线程都有自己独立的程序计数器、虚拟机栈和本地方法栈,线程之间切换的开销小。
  • 包含关系:一般一个进程内有多个线程,执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。
  • 影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响。但是一个线程崩溃可能导致整个进程都死掉。所以多进程要比多线程健壮。
  • 通信方面:进程间通信较为复杂 同一台计算机的进程通信称为 IPC;不同计算机之间的进程通信,需要通过网络,并遵守共同的协议,例如 HTTP。

2. 并发与并行

  • 并行是指两个或者多个事件在同一时刻发生
  • 并发是指两个或多个事件在同一时间间隔发生

同步与异步
需要等待结果返回才能继续运行的话就是同步
不需要等待就是异步

结论

  1. 单核 cpu 下,多线程不能实际提高程序运行效率,只是为了能够在不同的任务之间切换,不同线程轮流使用 cpu ,不至于一个线程总占用 cpu,别的线程没法干活

  2. 多核 cpu 可以并行跑多个线程,但能否提高程序运行效率还是要分情况的

    • 有些任务,经过精心设计,将任务拆分,并行执行,当然可以提高程序的运行效率。但不是所有计算任务都能拆分(阿姆达尔定律)

    • 也不是所有任务都需要拆分,任务的目的如果不同,谈拆分和效率没啥意义

  3. IO 操作不占用 cpu,只是我们一般拷贝文件使用的是【阻塞 IO】,这时相当于线程虽然不用 cpu,但需要一 直等待 IO 结束,没能充分利用线程。


二、java线程

1、线程创建与运行

1. 使用 Thread
创建继承于Thread类的子类,并重写Thread类的run()方法

public static void main(String[] args) {
	// 匿名内部类方式创建 Thread  创建进程的时候最好指定个名称
    Thread t = new Thread("t1") {
    	@Override
    	public void run() {
    		// t1线程输出
    		log.debug("running");
    	}
	};
    t.start();
    
    // 主线程输出
    log.debug("running");
}


// lambda简化
@Test
public void test01() {
	Thread t = new Thread(() -> log.debug("running"));
	t.start();
}

2. 使用 Runnable 配合 Thread(推荐)

public static void main(String[] args) {
    Runnable r = new Runnable() {
        @Override
        public void run() {
            log.debug("running...");
        }
    };
    // 因为Thread线程类实现了接口Runnable 所以可以直接用Thread的构造函数传递
    Thread t = new Thread(r, "t2");
    t.start();
}

使用 lambda简化 上述操作

public static void main(String[] args) {
    // JDK会把只有1个抽象方法的接口加上 @FunctionalInterface 注解。有此注解可直接用lambda简化
    Runnable r = () -> log.debug("running");
    new Thread(r, "t1").start();
}

为啥使用 log 打印日志,而不是sout?因为sout加锁了(synchronized),加锁好像会同步执行
方法二,Runnable对象作为参数走的是Thread的原始run()方法;而方法一,实际线程调用的的是重写后的run()方法。

3. FutureTask配合Thread
FutureTask能够接收Callable类型的参数,用来处理有返回结果的情况。即FutureTask是在Runnable基础上加了返回值。

public static void main(String[] args) throws ExecutionException, InterruptedException {
    FutureTask<Integer> task = new FutureTask<>(new Callable<Integer>() {
        @Override
        public Integer call() throws Exception {
            log.debug("running...");
            Thread.sleep(2000);
            return 100;
        }
    });

    Thread t1 = new Thread(task, "t1");
    t1.start();
    // 注意,当要获取进程的返回结果时,会阻塞到结果的返回。即如果此进程需要执行2s,那么会卡在这里2s。
    log.debug("{}", task.get());
}

lambda简化

public static void main(String[] args) throws Exception {
    FutureTask<Integer> future = new FutureTask<Integer>(() -> {
        log.debug("running...");
        Thread.sleep(2000);
        return 100;
    });
    
    // 2. 传入 future, 因为 FutureTask 这个类是实现了 RunnableFuture 接口,RunnableFuture 继承了 Runnable 接口
    new Thread(future, "t1").start();
    
    // 3. 主线程阻塞,同步等待 future 执行完毕的结果。如果不调用获取返回值方法不会阻塞。
    Integer result = future.get();
    log.debug("{}",  result);
}

2. 线程上下文切换

因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码

  • 线程的 cpu 时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法

当 上下文切换 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的。

  • 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
  • 上下文切换 频繁发生会影响性能

所以说,线程数不是开的越多越好,因为线程的上下文切换都需要时间,开的线程越多,整体的吞吐量越高

4、Thread 的常见方法

1. start() 与 run()

使用 start 方式,CPU 会为创建的线程分配时间片,线程进入运行状态(实际处于就绪状态,只不过java中就绪与运行都是运行状态),然后线程调用 run 方法执行逻辑。(run才是真正的运行状态 被cpu调度执行)

2. sleep()与yield()

sleep

  1. 调用 sleep 会让当前线程从 Running 进入 Timed Waiting 状态(阻塞)
  2. 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出 InterruptedException
  3. 睡眠结束后的线程未必会立刻得到执行
  4. 建议用 TimeUnit 的 sleep 代替 Thread 的 sleep 来获得更好的可读性

sleep()在哪个线程中被调用,就让哪个线程睡眠

yield

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

yield会让当前线程让出cpu的使用权,但是可能出现,刚让出使用权后,又获得了cpu的时间片
就绪状态有机会获得时间片,阻塞状态不能获得时间片。阻塞状态需要等待阻塞状态过后进入就绪状态

3. join() 方法

用于等待某个线程结束。哪个线程内调用join()方法,就等待哪个线程结束,然后再去执行其他线程。
如在主线程中调用ti.join(),则是主线程等待t1线程结束,join 采用同步。

Thread t1 = new Thread();
// 等待 t1 线程执行结束
t1.join();
// 最多等待 1000ms,如果 1000ms 内线程执行完毕,则会直接执行下面的语句,不会等够 1000ms。可能会提前结束
t1.join(1000);

// sleep等待的时间是固定的(即必须等多久)

4. interrupt 方法

打断 sleep,wait,join 的线程。这几个方法都会让线程进入阻塞状态
打断 sleep 的线程, 会清空打断状态,以 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()); // false
}

注意:打断 sleep,wait,join 的线程。当线程处于阻塞状态时被打断才会标志位false线程在执行时被打断,标志位为true

7. 线程的状态(生命周期)

1. 线程的 5 种状态

1.新建(new):新创建了一个线程对象。

2.就绪(runnable):线程对象创建后,当调用线程对象的 start()方法,该线程处于就绪状态,等待被线程调度选中,获取cpu的使用权。

3.运行(running):可运行状态(runnable)的线程获得了cpu时间片(timeslice),执行程序代码。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;

4.阻塞(block):处于运行状态中的线程由于某种原因,暂时放弃对 CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被 CPU 调用以进入到运行状态。

  • 等待阻塞:运行状态中的线程执行 wait()方法,JVM会把该线程放入等待队列(waitting queue)中,使本线程进入到等待阻塞状态;
  • 同步阻塞:线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),,则JVM会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态;
  • 其他阻塞: 通过调用线程的 sleep()或 join()或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。

5.死亡(dead):线程run()、main()方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。死亡的线程不可再次复生。


三、共享模型之管程

1. 线程共享带来的问题

线程出现问题的根本原因是因为线程上下文切换,导致线程里的指令没有执行完就切换执行其它线程了。
例子

// 静态变量是上下文共享的
// 静态变量是上下文共享的
public static int count = 0;

public static void main(String[] args) throws InterruptedException {
    // 线程t1对共享变量count进行修改
    Thread t1 = new Thread(() -> {
        for (int i = 1;i < 5000; i++){
            count++;
        }
    });
    // 线程t2对共享变量count进行修改
    Thread t2 = new Thread(() -> {
        for (int i = 1;i < 5000; i++){
            count--;
        }
    });
    // 启动线程
    t1.start();
    t2.start();
    // main等待t1结束,t1在跑时,t2也同时在跑
    t1.join();
    t2.join();
    // 反正很难为0,值不确定
    log.debug("count的值是{}",count);
}

因为 Java 中对静态变量的自增,自减并不是原子操作,如上代码,当执行 count++(count是静态变量) 或者 count-- 操作的时候,从字节码分析,实际上是 4 步操作。

count++; // 操作字节码如下:
    getstatic i // 获取静态变量i的值
    iconst_1 // 准备常量1
    iadd // 自增
    putstatic i // 将修改后的值存入静态变量i
	
count--; // 操作字节码如下:
    getstatic i // 获取静态变量i的值
    iconst_1 // 准备常量1
    isub // 自减
    putstatic i // 将修改后的值存入静态变量i

由于不是原子性操作,多线程下这 8 行代码可能交错运行,多线程cpu分时时间片执行

  1. 出现负数的情况
    image
    如图,当t1分到了cpu时间片执行指令,对count--进行计算,但还没写入count,就没得时间片了。
    于是t2抢到了时间片对count++完整计算后,线程1再次获得时间片,将-1的结果写入count,覆盖了count++的结果

出现正数的原理也差不多这意思

所以,所谓线程安全,是指多个线程对同一个共享资源进行的修改

2.临界区

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

个人理解:共享变量+读写操作+多线程 = 临界区

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

3. synchronized 重量级锁

为了避免临界区中的竞态条件发生,由多种手段可以达到。

  • 阻塞式解决方案:synchronized ,Lock
  • 非阻塞式解决方案:原子变量

synchronized 俗称的对象锁,它采用互斥的方式让同一时刻至多只有一个线程持有对象锁,其他线程如果想获取这个锁就会阻塞住,这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换。

1.synchronized 语法

synchronized(对象) { // 线程1(获得锁), 线程2(blocked阻塞)
	//临界区
}

重量级锁解决上述案例

    static final Object room = new Object();
	
    @Test
    public void test5() throws InterruptedException {
        // 线程t1对共享变量count进行修改
        Thread t1 = new Thread(() -> {
            for (int i = 1; i < 5000; i++) {
                // 对共享资源加锁
                synchronized (room) {
                    count++;
                }
            }
        });
        // 线程t2对共享变量count进行修改
        Thread t2 = new Thread(() -> {
            for (int i = 1;i < 5000; i++){
                // 对共享资源加锁
                synchronized (room) {
                    count--;
                }
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
		// count = 0
        log.info("count的值是 " + count);
    }

如果临界区是WC,对象锁是钥匙,则只有有钥匙的人才能进去,而其他人只能在外边排队
对象锁被t1线程拿走,即使失去时间片,也不会归还对象锁,直到代码块中内容执行完。才会释放对象锁,而后其他线程竞争对象锁。
由此可以看出,对象锁的效率挺低的

对象锁相当于将临界区变成了原子性整体(不能被线程分割)。
如果把 synchronized(obj) 放在 for 循环的外面,如何理解?

// 如果将对象锁放在了for循环外,相当于将5000次循环作为了一个原子性整体,不会被其他线程干扰,即只有线程1对变量进行了5000次操作,其他线程才能对共享变量操作
Thread t1 = new Thread(() -> {
	synchronized (room) {
		for (int i = 1; i < 5000; i++) {
			count++;
		}
	}
});

如果 t1 synchronized(obj1) 而 t2 synchronized(obj2) 会怎样运作?

// 需要对同一个对象加锁,不然跟没加锁效果一样
Thread t1 = new Thread(() -> {
	for (int i = 1; i < 5000; i++) {
		synchronized (room) {
			count++;
		}
	}
});
// 线程t2对共享变量count进行修改
Thread t2 = new Thread(() -> {
	for (int i = 1;i < 5000; i++){
		synchronized (room1) {
			count--;
		}
	}
});

2. 方法上的synchronized

加在成员方法上,锁住的是对象

class Test{
    // 在方法上加上synchronized关键字
    public synchronized void test() {
    
    }
}
等价于
class Test{
    public void test() {
        synchronized(this) { // 锁住的是对象
        
        }
    }
}

加在静态方法上,锁住的是类对象

public class Test {
	// 在静态方法上加上 synchronized 关键字
	public synchronized static void test() {
	
	}
	//等价于
	public void test() {
		synchronized(Test.class) { // 锁住的是类
		
		}
	}
}

区别:锁对象锁的是当前对象,锁类对象锁的是调用这个方法的所有对象
锁对象锁

class Number{
    public synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}
// 输出结果:2,1秒后 1
// a()锁的是 n1对象,b()锁的是 n2对象。于是达不到互斥效果。即跟没锁一样
public static void main(String[] args) {
    Number n1 = new Number();
    Number n2 = new Number();
    new Thread(()->{ n1.a(); }).start();
    new Thread(()->{ n2.b(); }).start();
}

锁类对象锁

class Number{
    public static synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public static synchronized void b() {
        log.debug("2");
    }
}
// 输出结果:2,1秒后 1   或   1秒后 1,2
// a()锁的是 Number类对象,b()锁的是 Number类对象。达成互斥效果。
public static void main(String[] args) {
    Number n1 = new Number();
    Number n2 = new Number();
    new Thread(()->{ n1.a(); }).start();
    new Thread(()->{ n2.b(); }).start();
}

最容易错:

class Number{
    public static synchronized void a() {
        sleep(1);
        log.debug("1");
    }
    public synchronized void b() {
        log.debug("2");
    }
}
// 输出结果:2,1秒后 1
// a()锁的是 Number类对象,b()锁的是 n1对象。达不成互斥效果。即跟没锁一样
public static void main(String[] args) {
    Number n1 = new Number();
    new Thread(()->{ n1.a(); }).start();
    new Thread(()->{ n1.b(); }).start();
}

4. 变量的线程安全分析

1. 成员变量和静态变量的线程安全分析

变量在线程间共享期间,如果变量有读写操作,则这段代码就是临界区,需要考虑线程安全问题

2. 局部变量是否线程安全

  • 局部变量【局部变量被初始化为基本数据类型】是安全的
  • 局部变量是引用类型或者是对象引用则未必是安全的
    • 如果该对象没有逃离方法的作用范围,它是线程安全的
    • 如果该对象逃离方法的作用范围,需要考虑线程安全

所谓逃离方法作用范围,指的是return变量给其他方法用

3. 局部变量线程安全分析

  1. 局部变量为基本类型
    每个线程都有属于自己的栈空间,基本数据类型变量会存放在各自的栈空间中。互不影响,所以并不存在线程安全问题

  2. 局部变量为引用类型

public final void method1() {
	MyAspect myAspect = new MyAspect();
	System.out.println(myAspect);
}
@Test
public void testMet() {
	for (int i = 0; i < 3; i++) {
		new Thread(this::method1, "Thread" + i).start();
	}
}

myAspect对象是局部变量,每个线程调用时会创建其不同实例,没有共享。我这里打印了其地址,很明细不一致,地址不一致代表对象不是同一个

结论,引用类型的局部变量,只要不发生逃逸,是不会产生线程安全的

4. 常见线程安全类

String
Integer
StringBuffer
Random
Vector
Hashtable
java.util.concurrent 包下的类

这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。

// 创建一个实例
Hashtable table = new Hashtable();
 
new Thread(()->{
	// 调用这个实例的同一个方法
    table.put("key", "value1");
}).start();
 
new Thread(()->{
	// 调用这个实例的同一个方法
    table.put("key", "value2");
}).start();

它们的每个方法是原子的;但注意它们多个方法的组合不是原子的

Hashtable table = new Hashtable();
// 线程1,线程2
// 这里的get是原子的
if( table.get("key") == null) {
	// 这里的put也是原子的
    table.put("key", value);
}
// 但是它俩组合,就不安全了

线程1在get("key") == null时,就已经释放锁了,因为get是线程安全的。
如果此时失去了cpu时间片,t1执行完get释放了锁,此时t1还没执行put;而t2也可以执行get,也进入了if,然后put覆盖了table对象值。

5. 不可变类线程安全性

String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的

String 中的 replace,substring 等方法"可以"改变值啊,那么这些方法又是如何保证线程安全的呢?
比如:String.substring() 源码
image

其实内部实现没有改变属性,而是创建了一个新的对象返回。因此不可变对象和无状态对象都是线程安全的

6. Monitor 概念

1. Java 对象头

无论多少位虚拟机的对象头都是8字节
一个对象的结构如下:
image

// int 4字节   Integer:8(对象头)+4(int)+对其填充=16字节(虚拟机要求对象起始地址必须是8字节的整数倍。)

2. Monitor 原理(重量级锁)

每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针
image
以故事形式:Monitor 是一个带锁和钥匙的食堂,下课后,一堆同学涌进来想吃饭。

小明先拿到了钥匙(执行了synchronized(obj)),成为了食堂的主人(Owner)。小明进房间吃饭,其他没抢到钥匙锁的就只能去等待室(EntryList)排队等候。

WaitSet:之前有钥匙锁进房间吃饭,然后中途休息(wait())的休息室(进WaitSet会释放锁)。只能被食堂主人(Owner)叫醒(notify()|notifyAll()),叫醒后还要去等待室排队。

当小明吃完饭,释放了钥匙锁,等待室的人就可以继续竞争了(竞争的时是非公平的)


-------------synchronized锁升级没搞明白-------------


3. Wait 与 Sleep 的区别

  • Sleep 是 Thread 类的静态方法,Wait 是 Object 的方法,Object 又是所有类的父类,所以所有类都有Wait方法。
  • Sleep 在阻塞的时候不会释放锁,而 Wait 在阻塞的时候会释放锁,它们都会释放 CPU 时间片资源。
  • Sleep 不需要与 synchronized 一起使用,而 Wait 需要与 synchronized 一起使用(对象被锁以后才能使用)
  • 使用 wait 一般需要搭配 notify 或者 notifyAll 来使用,不然会让线程一直等待。
  • wait()会进入waiting状态
  • 用途不同:Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。

sleep(long n) 和 wait(long n) 的相同点

1.均可响应中断 2.都会进入timed_waiting状态

posted @   原来人生只有十二集  阅读(58)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
点击右上角即可分享
微信分享提示