Potato icon indicating copy to clipboard operation
Potato copied to clipboard

Java Thread

Open yunshuipiao opened this issue 5 years ago • 0 comments

Java Thread

[TOC]

这篇文章了解一下 多线程 相关的知识。

线程底层原理

java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。

synchronized会导致争用不到锁的线程进入阻塞状态,所以说它是java语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM从1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。所以明确java线程切换的代价,是理解java中各种锁的优缺点的基础之一。

用户态和内核态的概念

内核态:CPU可以访问内存所有数据, 包括外围设备, 例如硬盘, 网卡. CPU也可以将自己从一个程序切换到另一个程序

用户态:只能受限的访问内存, 且不允许访问外围设备. 占用CPU的能力被剥夺, CPU资源可以被其他程序获取

用户态与内核态的切换

用户态程序切换到内核态, 但是不能控制在内核态中执行的指令。

这种机制叫系统调用, 在CPU中的实现称之为陷阱指令(Trap Instruction)

工作流程:

  1. 用户态程序将一些数据值放在寄存器中, 或者使用参数创建一个堆栈(stack frame), 以此表明需要操作系统提供的服务.
  2. 用户态程序执行陷阱指令
  3. CPU切换到内核态, 并跳到位于内存指定位置的指令, 这些指令是操作系统的一部分, 他们具有内存保护, 不可被用户态程序访问
  4. 这些指令称之为陷阱(trap)或者系统调用处理器(system call handler). 他们会读取程序放入内存的数据参数, 并执行程序请求的服务
  5. 系统调用完成后, 操作系统会重置CPU为用户态并返回系统调用的结果

线程基本介绍

线程:一个基本的 CPU 执行单元,程序执行的最小单位

  • 比进程更小,可独立运行
  • 组成:线程ID + 程序计数器 + 寄存器集合 + 堆栈
  • 与其他线程共享进程所拥有的全部资源

作用:减少程序在并发执行时所付出的时空开销,提高系统的并发性能。

拥有就绪,阻塞,运行三种基本状态。

线程控制模块

当我们构造一个线程,java虚拟机会在内存中生成一个线程控制块,其中包括PC寄存器、Java栈、本地方法栈,这是每个线程独自拥有的,互不干涉。

PC计数器:存放当前正在被执行的字节码指令(JVM指令)的地址。说白了,就是PC计数器用来记住这个线程被执行到那一步了(方便下次继续执行)。

Java栈:这个栈中存放着一系列的栈帧(Stack Frame),JVM只能进行压入(Push)和弹出(Pop)栈帧这两种操作。每当调用一个方法时,JVM就往栈里压入一个栈帧,方法结束返回时弹出栈帧。每个栈帧包含三个部分:本地变量数组、操作数栈(操作数栈中存放方法执行时的一些中间变量,JVM在执行方法时压入或者弹出这些变量。其实,操作数栈是方法真正工作的地方,其中我们定义的各种基础数据类型的变量,和对象的引用变量都在操作数栈的内存中储存。当一个函数执行完后,它对栈内存的占用也会被释放,供下一个函数使用)、方法所属类的常量池引用。

**本地方法栈:**这个栈用来存放本地语言(如C或者C++代码)的方法调用信息,我们知道java是通过在操作系统的基础上虚拟出一层环境(称为JRE)来运行我们的java 程序的,编写操作系统的语言多数时候并不是java(例如windows和linux都是C语言编写的),当程序通过JNI(Java Native Interface)调用本地方法(如C或者C++代码)时,就根据本地方法的语言类型建立相应的栈。

线程基本方法介绍

基本的知识如下:

  1. 每个对象都有一个锁来控制同步访问,Synchronized 关键字可以和对象的锁交互,来实现同步方法或者同步块。sleep() 方法正在执行的线程主动让出 CPU (然后 CPU 就可以去执行其他任务),在 sleep() 指定时间后 CPU 再回到该线程继续往下执行(sleep 方法只是让出 CPU,而不会释放同步资源锁);

    /而 wait() 方法则是当前线程让自己暂时退出资源同步锁,以便其他正在等待该资源的线程得到该资源进而运行,只有调用 notify() 方法之后,之前调用 wait() 的线程才会接触 wait 状态,可以参与竞争同步资源锁,进而得到执行的机会,

    (注意:notify 只是让之前调用 wait 的线程有权利重新参与线程的调度)

  2. sleep() 可以在任何地方调用;wait() 方法只能在同步方法或者同步代码块中调用。

package test

val lock = Object()

object MultiThread {
    @JvmStatic
    fun main(args: Array<String>) {
        Thread1().start()
        Thread2().start()
    }
}

class Thread1 : Thread(Runnable {
    synchronized(lock) {
        (0 until 10).forEach {
            if (it == 3) {
                lock.wait()
           }
            println("${Thread.currentThread()} -- $it")
            Thread.sleep(500)
        }
    }

}, "Thread1")

class Thread2 : Thread(Runnable {
    synchronized(lock)
    {
        (0 until 10).forEach {
            if (it == 4) {
                lock.notify()
            }
            println("${Thread.currentThread()} -- $it")
            Thread.sleep(500)
        }
    }
}, "Thread2")

sleep 和 yield 的区别

两个方法都是 Thread 类中的静态方法, 都会使得当前处于运行状态的线程放弃 CPU。

区别如下:

  1. sleep 使当前线程暂停一段时间,给其他线程运行的机会,不考虑优先级,不释放锁资源;yield 只会让位给优先级一样或者高于它的线程。
  2. 当线程执行 sleep 之后,线程进入睡眠状态,知道时间结束;而 yield 直接转到就绪状态。

正确停止线程

之前的 stop() 方法强制终止一个线程的执行,可能会造成数据不一致。

现在常用的是线程中断的方法:不会使线程立刻退出,而是给线程发送一个通知,告诉目标线程,需要退出。

使用Thread.interrupt()方法处理现场中断,需要使用Thread.isInterrupted()判断线程是否被中断,然后进入中断处理逻辑代码。

死锁

手写死锁代码:

package test

val A = Object()
val B = Object()

object DeathLock {
    @JvmStatic
    fun main(args: Array<String>) {
        Thread3().start()
        Thread4().start()
    }
}

class Thread3 : Thread(Runnable {
    synchronized(A) {
        println("${Thread.currentThread()} has A lock: ${Thread.holdsLock(A)}")
        try {
            // 防止这个线程启动一下连续获得o1和o2两个对象的锁
            Thread.sleep(1000)
        } catch (e: Exception) {
        }
        synchronized(B) {
                println("${Thread.currentThread()} has B lock :${Thread.holdsLock(B)}")
            }
    }

}, "Thread3")

class Thread4 : Thread(Runnable {
    synchronized(B) {
        println("${Thread.currentThread()} has B lock: ${Thread.holdsLock(B)}")
        synchronized(A) {
            println("${Thread.currentThread()} has A lock: ${Thread.holdsLock(A)}")
        }

    }
}, "Thread4")

一般来说:死锁的出现有四个条件:

  • 互斥条件:一个资源每次只能被一个线程使用
  • 请求和保持条件:一个线程因请求资源而阻塞,对已获得的资源保持不放
  • 不剥夺条件:对线程已获得的资源,在未使用完之前, 不能强行剥夺
  • 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。

破坏其中一个条件即可解决死锁问题。

Synchronized

作用:被该关键字修饰的代码块,保证同一时刻最有只有一个线程执行

其他线程 必须等待当前线程执行完该代码块之后才能执行

解决多线程下的并发同步问题(阻塞性并发),保证线程安全。

原理

依赖 JVM 实现同步,底层通过一个监视器对象 monitor 对象完成,wati(), notify() 方法也依赖于此对象。

monitor 的本质依赖于底层操作系统的互斥锁

具体使用

用于修饰类的实例方法,代码块和静态方法

锁对象:

  • 代码块:需要一个引用对象作为锁的对象
  • 类的实例方法:当前类对象
  • 静态方法:当前类的 Class 对象

其中对象锁和类锁的区别如下

类型 锁对象 锁的数量 表现形式 应用场景
对象锁(含方法锁) 实例对象 多个 Synchronized 修饰的方法和代码块 控制方法之间的同步
类锁 Class 对象 一个 Synchronized 修饰的静态方法和静态代码块 控制静态方法(静态变量互斥)的同步

特点

  • 保证有序性,原子性,和可见性
  • 可重入性:对同一个线程获得锁后,在调用其他需同样锁的代码时可直接调用
  • 重量级:操作系统实现线程切换需要从用户态切换到内核态,时间较长,因此效率低。

ReentrantLock

与 Synchronized 同样作为同步的方式。前者使用简单,不需要显示释放。

而后者需要解锁操作。

为什么引入 ReentrantLock:可重入,可中断,可限时,公平锁等特点

Volatile

首先 volatile 关键字的使用跟 java 内存模型,并发编程中的原子性,有序性和可见性相关。

通过 Synchronized 和 Lock 都能保证并发的三个特性。

java 内存模型

规定所有的变量都是存在主存中,每个线程都有自己的工作内存。线程相对变量的所有操作都必须在工作内存中进行,而不能直接对主存操作;并且每个线程不能其他线程的工作内存。

两层意思

一旦一个变量被 volatile 修饰之后,那么具备下述两层语义:

  • 保证不同线程对这个变量进行操作是的可见性。即一个线程修改,另一个线程可见
  • 禁止进行指令重排序

原理和实现机制

观察加入 volatile 关键字和没有加入 volatile 关键字所生成的汇编代码发现,加入之后,会多出一个 lock 前缀指令

lock 前缀指令实际上相当于一个内存屏障(内存栅栏),提供 3 个功能。

  1. 确保指令重排序不会把其后面的指令排到内存屏障之前的位置,也不会把前面指令排到内存屏障的后面;

    即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

  2. 强制将对缓存的修改操作立即写入到主存

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

使用场景

使用前提:

  1. 对变量的写操作不依赖当前值
  2. 该变量没有包含在其他变量的不变式中

内存屏障

由于现代的操作系统都是多处理器.而每一个处理器都有自己的缓存,并且这些缓存并不是实时都与内存发生信息交换.这样就可能出现一个cpu上的缓存数据与另一个cpu上的缓存数据不一致的问题.而这样在多线程开发中,就有可能导致出现一些异常行为.

而操作系统底层为了这些问题,提供了一些内存屏障用以解决这样的问题.目前有4种屏障.

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

使用

  • 通过 Synchronized关键字包住的代码区域,当线程进入到该区域读取变量信息时,保证读到的是最新的值.这是因为在同步区内对变量的写入操作,在离开同步区时就将当前线程内的数据刷新到内存中,而对数据的读取也不能从缓存读取,只能从内存中读取,保证了数据的读有效性.这就是插入了StoreStore屏障
  • 使用了volatile修饰变量,则对变量的写操作,会插入StoreLoad屏障.
  • 其余的操作,则需要通过Unsafe这个类来执行.

yunshuipiao avatar May 26 '19 06:05 yunshuipiao