Java并发编程-01

进程和线程的区别

进程和线程都是操作系统用于并发执行的方式,但是它们在资源管理、独立性、开销以及影响范围等方面有所不同。

*进程是操作系统分配资源的基本单位,线程是操作系统调度的基本单位。

*进程拥有独立的内存空间,线程共享所属进程的内存空间。

*进程的创建和销毁需要资源的分配和回收,开销较大;线程的创建和销毁只需要保存寄存器和栈信息,开销较小。

*进程间的通信比较复杂,而线程间的通信比较简单。

*进程间是相互独立的,一个进程崩溃不会影响其他进程;线程间是相互依赖的,一个线程崩溃可能影响整个程序的稳定性。

创建线程的三种方式

*创建一个类继承 Thread 类,并重写 run 方法。

*创建一个类实现 Runnable 接口,并重写 run 方法。

*实现 Callable 接口,重写 call 方法,这种方式可以通过 FutureTask 获取任务执行的返回值。

为什么要重写 run 方法?

因为默认的run()方法不会做任何事情。 为了让线程执行一些实际的任务,我们需要提供自己的run()方法实现,这就需要重写run()方法。

run 方法和 start 方法有什么区别?

***run()**:封装线程执行的代码,直接调用相当于调用普通方法。

***start()**:启动线程,然后由 JVM 调用此线程的 run() 方法。

通过继承 Thread 的方法和实现 Runnable 接口的方式创建多线程,哪个好?

实现 Runable 接口好:

*避免了 Java 单继承的局限性,Java 不支持多重继承,因此如果我们的类已经继承了另一个类,就不能再继承 Thread 类了。

*适合多个相同的程序代码去处理同一资源的情况,把线程、代码和数据有效的分离,更符合面向对象的设计思想。Callable 接口与 Runnable 非常相似,但可以返回一个结果。

线程的6种状态

线程被视为轻量级的进程,所以线程状态其实和进程状态是一致的。
NEW
处于 NEW 状态的线程此时尚未启动。这里的尚未启动指的是还没调用 Thread 实例的start()方法。

RUNNABLE
表示当前线程正在运行中。处于 RUNNABLE 状态的线程在 Java 虚拟机中运行,也有可能在等待 CPU 分配资源。

Java 线程的RUNNABLE状态其实包括了操作系统线程的ready和running两个状态。

BLOCKED
阻塞状态。处于 BLOCKED 状态的线程正等待锁的释放以进入同步区。

WAITING
等待状态。处于等待状态的线程变成 RUNNABLE 状态需要其他线程唤醒。

3 个方法会使线程进入等待状态:

*Object.wait():使当前线程处于等待状态直到另一个线程唤醒它;

*Thread.join():等待线程执行完毕,底层调用的是 Object 的 wait 方法;

*LockSupport.park():除非获得调用许可,否则禁用当前线程进行线程调度。

TIMED_WAITING
超时等待状态。线程等待一个具体的时间,时间到后会被自动唤醒。

如下方法会使线程进入超时等待状态:

*Thread.sleep(long millis):使当前线程睡眠指定时间;

*Object.wait(long timeout):线程休眠指定时间,等待期间可以通过notify()/notifyAll()唤醒;

*Thread.join(long millis):等待当前线程最多执行 millis 毫秒,如果 millis 为 0,则会一直执行;

*LockSupport.parkNanos(long nanos): 除非获得调用许可,否则禁用当前线程进行线程调度指定时间;

*LockSupport.parkUntil(long deadline):同上,也是禁止线程进行调度指定时间;

TERMINATED
终止状态。此时线程已执行完毕。

线程状态转换图

Java的内存模型(JMM)

*Java 内存模型(JMM)定义了 Java 程序中的变量、线程如何和主存以及工作内存进行交互的规则。它主要涉及到多线程环境下的共享变量可见性、指令重排等问题,是理解并发编程中的关键概念。

*Java 内存模型(JMM)主要针对的是多线程环境下,如何在主内存与工作内存之间安全地执行操作。

*Java 运行时内存区域描述的是在 JVM 运行时,如何将内存划分为不同的区域,并且每个区域的功能和工作机制。主要包括方法区、堆、栈、本地方法栈、程序计数器。

*指令重排是为了提高 CPU 性能,但是可能会导致一些问题,比如多线程环境下的内存可见性问题。

*happens-before 规则是 JMM 提供的强大的内存可见性保证,只要遵循 happens-before 规则,那么我们写的程序就能保证在 JMM 中具有强的内存可见性。

volatile关键字

volatile 可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在 JVM 底层 volatile 是采用“内存屏障”来实现的。

观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码就能发现,加入 volatile 关键字时,会多出一个 lock 前缀指令,lock 前缀指令实际上相当于一个内存屏障(也称内存栅栏),内存屏障会提供 3 个功能:

*它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

*它会强制将对缓存的修改操作立即写入主存;

*如果是写操作,它会导致其他 CPU 中对应的缓存行无效。

synchronized关键字

synchronized 可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作),
同时我们还应该注意到 synchronized 的另外一个重要的作用,synchronized 可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代 volatile 功能)。

synchronized 关键字最主要有以下 3 种应用方式:

*同步方法,为当前对象(this)加锁,进入同步代码前要获得当前对象的锁;

*同步静态方法,为当前类加锁(锁的是 Class 对象),进入同步代码前要获得当前类的锁;

*同步代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。

synchronized属于可重入锁:从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功。

同步会带来一定的性能开销,因此需要合理使用。不应将整个方法或者更大范围的代码块做同步,而应尽可能地缩小同步范围。

synchronized的四种锁状态

在 JDK 1.6 及其以后,一个对象其实有四种锁状态,它们级别由低到高依次是:

*无锁状态

*偏向锁状态

*轻量级锁状态

*重量级锁状态

几种锁会随着竞争情况逐渐升级,锁的升级很容易发生,但是锁降级发生的条件就比较苛刻了,锁降级发生在 Stop The World期间,当 JVM 进入安全点的时候,会检查是否有闲置的锁,然后进行降级。

无锁
无锁就是没有对资源进行锁定,任何线程都可以尝试去修改它。

偏向锁
偏向锁会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。也就是说,偏向锁在资源无竞争情况下消除了同步语句,连 CAS操作都不做了,着极大地提高了程序的运行性能。

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

轻量级锁
多个线程在不同时段获取同一把锁,即不存在锁竞争的情况,也就没有线程阻塞。针对这种情况,JVM 采用轻量级锁来避免线程的阻塞与唤醒。

重量级锁
重量级锁依赖于操作系统的互斥锁(mutex,用于保证任何给定时间内,只有一个线程可以执行某一段特定的代码段) 实现,而操作系统中线程间状态的转换需要相对较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU。

锁的升级流程

每一个线程在准备获取共享资源时:
*①检查 MarkWord 里面是不是放的自己的 ThreadId ,如果是,表示当前线程是处于 “偏向锁” 。

*②如果 MarkWord 不是自己的 ThreadId,锁升级,这时候,用 CAS 来执行切换,新的线程根据 MarkWord 里面现有的 ThreadId,通知之前线程暂停,之前线程将 Markword 的内容置为空。

*③两个线程都把锁对象的 HashCode 复制到自己新建的用于存储锁的记录空间,接着开始通过 CAS 操作,
把锁对象的 MarKword 的内容修改为自己新建的记录空间的地址的方式竞争 MarkWord。

*④第③步中成功执行 CAS 的获得资源,失败的则进入自旋 。

*⑤自旋的线程在自旋过程中,成功获得资源(即之前获的资源的线程执行完成并释放了共享资源),则整个状态依然处于 轻量级锁的状态,如果自旋失败 。

*⑥进入重量级锁的状态,这个时候,自旋的线程进行阻塞,等待之前线程执行完成并唤醒自己。

CAS

CAS(Compare-and-Swap)是一种乐观锁的实现方式,全称为“比较并交换”,是一种无锁的原子操作。

在 CAS 中,有这样三个值: V:要更新的变量(var)、E:预期值(expected)和N:新值(new)。

比较并交换的过程如下: 判断 V 是否等于 E,如果等于,将 V 的值设置为 N;如果不等,说明已经有其它线程更新了 V,于是当前线程放弃更新,什么都不做。

当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败,但失败的线程并不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。

CAS 的三大问题

尽管 CAS 提供了一种有效的同步手段,但也存在一些问题,主要有以下三个:ABA 问题、长时间自旋、多个共享变量的原子操作。
ABA 问题
所谓的 ABA 问题,就是一个值原来是 A,变成了 B,又变回了 A。这个时候使用 CAS 是检查不出变化的,但实际上却被更新了两次。

ABA 问题的解决思路是在变量前面追加上版本号或者时间戳。从 JDK 1.5 开始,JDK 的 atomic 包里提供了一个类AtomicStampedReference类来解决 ABA 问题。

长时间自旋
CAS 多与自旋结合。如果自旋 CAS 长时间不成功,会占用大量的 CPU 资源。

解决思路是让 JVM 支持处理器提供的pause 指令。

pause 指令能让自旋失败时 cpu 睡眠一小段时间再继续自旋,从而使得读操作的频率降低很多,为解决内存顺序冲突而导致的 CPU 流水线重排的代价也会小很多。

多个共享变量的原子操作
当对一个共享变量执行操作时,CAS 能够保证该变量的原子性。但是对于多个共享变量,CAS 就无法保证操作的原子性,这时通常有两种做法:

*使用AtomicReference类保证对象之间的原子性,把多个变量放到一个对象里面进行 CAS 操作;

*使用锁。锁内的临界区代码可以保证只有当前线程能操作。

标题: Java并发编程-01

链接: http://example.com/2024/05/22/Java%E5%B9%B6%E5%8F%91%E7%BC%96%E7%A8%8B-01/

版权声明: 若无特殊标注皆为 鹏少 原创版权, 转载请以链接形式注明作者及原始出处

最后编辑时间: 2024-05-22