六、02【Java 多线程】之线程基础知识

  • Post author:
  • Post category:java


进程与线程

什么是进程

一个在内存中运行的应用程序。每个进程都有自己独立的一块内存空间,一个进程可以有多个线程,比如在Windows系统中,一个运行的xx.exe就是一个进程。进程是OS(操作系统)资源分配的最小单位。

什么是线程

进程中的一个执行任务(控制单元),负责当前进程中程序的执行。一个进程至少有一个线程,一个进程可以运行多个线程,多个线程可以访问共享的内存变量。线程是OS(操作系统)调度CPU的最小单元。CPU在这些线程上高速切换,让使用者感觉到这些线程在同时执行,即并发的概念,相似的概念还有并行!

什么是多线程

多线程是指程序中包含多个执行流,在一个程序中可以同时运行多个不同的线程来执行不同的任务。


多线程的好处:

可以提高 CPU 的利用率。在多线程程序中,一个线程必须等待的时候,CPU 可以运行其它的线程而不是等待,这样就大大提高了程序的效率。也就是说允许单个程序创建多个并行执行的线程来完成各自的任务。


多线程的劣势:

线程也是程序,所以线程需要占用内存,线程越多占用内存也越多;

多线程需要协调和管理,所以需要 CPU 时间跟踪线程;

线程之间对共享资源的访问会相互影响,必须解决竞用共享资源的问题。



总之使用多线程就是为了充分利用cpu的资源,提高程序执行效率,当你发现一个业务逻辑执行效率特别低,耗时特别长,就可以考虑使用多线程。

并行&并发&串行

并发:多个任务在同一个 CPU 核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行。

并行:单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的同时进行。

串行:有N个任务,由一个线程按顺序执行。由于任务、方法都在一个线程执行所以不存在线程不安全情况,也就不存在临界区的问题。

做一个形象的比喻:

并发 = 两个队列和一个售票窗口。

并行 = 两个队列和两个售票窗口。

串行 = 一个队列和一个售票窗口。

并发也可以说是充分利用多核CPU的计算能力,通过并发编程的形式可以将多核CPU的计算能力发挥到极致,性能得到提升。方便进行业务拆分,提升系统并发能力和性能:在特殊的业务场景下,先天的就适合于并发编程。现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。面对复杂业务模型,并行程序会比串行程序更适应业务需求,而并发编程更能吻合这种业务拆分 。

并发编程的目的就是为了能提高程序的执行效率,提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、上下文切换、线程安全、死锁等一系列的问题。

线程的上下文切换

多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。简单来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。


任务从保存到再加载的过程就是一次上下文切换。

上下文切换通常是计算密集型的。也就是说,它需要相当可观的处理器时间,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,可能是操作系统中时间消耗最大的操作。

Linux 相比与其他操作系统(包括其他类 Unix 系统)有很多的优点,其中有一项就是,其上下文切换和模式切换的时间消耗非常少。

线程的通知与等待

Object类是所有类的父类,Java就把所有类都需要的方法放在了Object类里面,并默认都继承了。

// 等待方法
public final void wait() throws InterruptedException {}
public final void wait(long timeout, int nanos) throws InterruptedException {}
public final native void wait(long timeout) throws InterruptedException;
// 通知方法
public final native void notify();
public final native void notifyAll();
// 克隆方法
protected native Object clone() throws CloneNotSupportedException;
// 转字符串方法
public String toString() {
    return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
// 哈希方法
public native int hashCode();

当一个线程调用共享变量的wait()方法时,该调用线程会被阻塞挂起,直到发生下面几件事才返回:

1)其他线程调用了该共享对象的notify()或notifyAll()方法;

2)其他线程调用了该线程的 interrupt()方法,该线程抛出InterruptedException异常返回。

需要注意的是:如果调用wait()方法的线程没有事先获取该对象的监视器锁,则调用wait()方法时调用线程抛出异常。

那如何获取共享对象的监视器锁呢?

// 执行synchronized同步代码块时,使用该共享对象作为参数
synchronized(共享变量){}
// 调用该共享对象的方法,并且该方法被synchronized修饰
synchronized void add(String name){}

一个线程也可以从挂起状态变为可以运行状态(也就是被唤醒),即使该线程没有被其他线程调用notify()或notifyAll()方法进行通知,或被中断,或等待超时。这就是虚假唤醒。

当一个线程调用共享对的notify()方法后,会唤醒一个在该共享对象上调用wait()方法后被挂起的线程。一个共享对象上可能会有很多个线程被挂起,至于具体唤醒那个等待的线程是随机的。当然这个被唤醒的线程不能马上从wait()方法返回继续执行,他必须获取了对象的监视器锁之后在可以返回。同样这个被唤醒的线程也不一定能获取到共享对象的监视器锁,因为这个线程还需要与其他线程竞争该共享对象的监视器锁。

线程死锁

死锁是指两个或两个以上的进程(线程)在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程(线程)称为死锁进程(线程)。

多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止。

如下图所示,线程 A 持有资源 2,线程 B 持有资源 1,他们同时都想申请对方的资源,所以这两个线程就会互相等待而进入死锁状态。

对应的代码就是这样的:

public class DeadLockSample {

    // 资源1
    private static Object resource1 = new Object();
    // 资源2
    private static Object resource2 = new Object();

    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + " 获取资源1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + " 等待获取资源2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + " 获取资源2");
                }
            }
        }, "线程 1").start();

        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + " 获取资源2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + " 等待获取资源1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + " 获取资源1");
                }
            }
        }, "线程2").start();
    }
}

输出结果:

Thread[线程1,5,main] 获取资源1
Thread[线程2,5,main] 获取资源2
Thread[线程1,5,main] 等待获取资源2
Thread[线程2,5,main] 等待获取资源1

线程 A 通过 synchronized (resource1) 获得 resource1 的监视器锁,然后通过Thread.sleep(1000);让线程 A 休眠 1s 为的是让线程 B 得到CPU执行权,然后获取到 resource2 的监视器锁。线程 A 和线程 B 休眠结束了都开始企图请求获取对方的资源,然后这两个线程就会陷入互相等待的状态,这也就产生了死锁。上面的例子符合产生死锁的四个必要条件。

形成死锁的必要条件

1)互斥:线程(进程)对于所分配到的资源具有排它性,即一个资源只能被一个线程(进程)占用,直到被该线程(进程)释放

2)请求与保持:一个线程(进程)因请求被占用资源而发生阻塞时,对已获得的资源保持不放。

3)不剥夺:线程(进程)已获得的资源在末使用完之前不能被其他线程强行剥夺,只有自己使用完毕后才释放资源。

4)循环等待:当发生死锁时,所等待的线程(进程)必定会形成一个环路(类似于死循环),造成永久阻塞

避免线程死锁

我们只要破坏产生死锁的四个条件中的其中一个就可以了。

1)破坏互斥条件

这个条件我们没有办法破坏,因为我们用锁本来就是想让他们互斥的(临界资源需要互斥访问)。

2)破坏请求与保持条件

一次性申请所有的资源。

3)破坏不剥夺条件

占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。

4)破坏循环等待条件

靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。破坏循环等待条件。

我们对线程 2 的代码修改成下面这样就不会产生死锁了。

new Thread(() -> {
    synchronized (resource1) {
        System.out.println(Thread.currentThread() + " 获取资源2");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread() + " 等待获取资源1");
        synchronized (resource2) {
            System.out.println(Thread.currentThread() + " 获取资源1");
        }
    }
}, "线程2").start();

输出结果

Thread[线程1,5,main] 获取资源1
Thread[线程1,5,main] 等待获取资源2
Thread[线程1,5,main] 获取资源2
Thread[线程2,5,main] 获取资源2
Thread[线程2,5,main] 等待获取资源1
Thread[线程2,5,main] 获取资源1

我们分析一下上面的代码为什么避免了死锁的发生?

线程1 首先获得到 resource1 的监视器锁,这时候线程2 就获取不到了。然后线程1 再去获取 resource2 的监视器锁,可以获取到。然后线程1 释放了对 resource1、resource2 的监视器锁的占用,线程2 获取到就可以执行了。这样就破坏了破坏循环等待条件,因此避免了死锁。

Java 锁机制

什么是锁

生活当中锁的分类:门锁、抽屉锁、手机锁、车锁等等。主要是为了保护我们的隐私资源不被别人获取;

那程序中的锁呢?程序中的锁一般出现在并发编程中,经常遇到多个线程访问同一个 共享资源 ,那线程就给这个资源上锁,那个线程拿到了锁,那个线程就有操作个资源的权利。那其他的线程就得排队等待。可以理解为 锁是作为并发共享数据,保证一致性的工具。

回归到生活当中,举个栗子:A/B/C三个人去超市购物,只有一个存包的柜子,谁先到抢到柜子的锁,就把包存进去,然后去购物。假如A先拿到,B和C就得在柜子前排队等着A归还这个锁,把自己的包存进去才可以去购物。

简单的理解下锁的概念之后,看看Java里面的锁吧。

上图就是一些锁的名称和功能。这些并不是指锁的状态,有的指锁的特性,有的指锁的设计。下面一一的介绍下:

那么在Java里有多种实现锁的方式(如 synchronized 和 ReentrantLock等等)。这些已经写好提供的锁为我们开发提供了便利,我们要去了解熟悉它,以便我们在开发遇到特殊场景能选择合适的锁来解决问题。

悲观锁&乐观锁

悲观锁是指对数据的操作持保守的态度,认为操作的数据很容易被其他线程修改,所以在操作数据之前要先对数据加锁。悲观的认为,不加锁的并发操作一定会出问题。在加锁的时候,会尝试为其加上排它锁。如果加锁失败,说明该数据正在被修改,那么则会等待或抛出异常。如果加锁获取成功,则对数据修改,完成之后解锁释放。

乐观锁则是对数据的操作保持乐观的态度,认为操作的数据不会被其他线程修改,那么在操作数据的时候不对数据加锁。乐观的认为,不加锁的并发操作是没有问题的。

通过上面的描述可知悲观锁和乐观锁是并不是一种锁的类型,而是一种思想看待并发同步的角度。

悲观锁在Java中的使用,就是利用各种锁。

乐观锁在Java中的使用,就是无锁编程,常常采用的是CAS算法【典型就是原子类,通过CAS自旋实现原子操作的更新】

自旋锁&适应性自旋锁

java中的线程与操作系统的线程是一一对应的,所以当一个线程获取锁失败后,会被切换到操作系统的内核状态而被挂起。当该线程获取到锁的时候又需要切换到内核状态唤醒该线程。从用户状态切换到内核状态的开销是比较大的,在一定程度上会影响并发的性能。那么就出现了自旋锁。

自旋锁则是当前线程在获取锁的时候,如果发现锁被其他线程占有,它不马上阻塞自己,在不放弃CPU使用权的情况下,多次尝试获取(默认10次,可以使用-XX:PreBlockSpinsh 参数设置该值),那么在这个尝试的过程中很有可能其他线程释放了锁,那么这个线程就可以拿到锁了,从而避免了被切换挂起。达到尝试次数之后还没获取到才会被切换挂起。自旋锁是使用CPU时间换取线程阻塞与调度的开销,但是很有可能这写CPU时间白白浪费了。

自适应自旋锁意味着自旋的次数不在固定,而是由前一次在同一个锁上的自旋时间和锁的拥有者的状态共同决定的。

如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很可能再次成功的,进而它将会允许线程自旋相对更长的时间。如果对于某个锁,线程很少成功获得过,则会相应减少自旋的时间甚至直接进入阻塞的状态,避免浪费处理器资源。

通过上面的描述可知自旋锁是一种偏底层一点的锁,有很多锁会基于自旋锁做一些调整。

公平锁&非公平锁

根据线程获取锁的抢占机制,分为公平锁和非公平锁。

公平锁则是线程获取锁会根据线程获取锁定的顺序也就是请求时间的早晚来决定的。先请求获取的最先得到,后来的就排队等待获取。

非公平锁则是在运行的时候闯入进来,插队,也就是先来的不一定先得到锁。

比如A/B/C三个线程,A持有锁,这时候B也想请求获取锁则会被挂起,需要A释放之后才能得到。此时C线程也想获取该锁,如果采用的是公平锁,那么C会被挂起,先让B拿到该锁。如果采用非公平锁,会根据线程策略,B和C其中一个可能获取锁,这时候不需要任何其他干涉。在没有公平性的前提要求下尽量使用非公平锁,因为公平锁会带来性能消耗。

通过上面的描述可知公平锁&非公平锁是一种获取锁的机制。

独占锁&共享锁

独占锁就是这个锁只能被单个线程占有。那如果这个锁可以被多个线程持有,那就是共享锁。

独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这就限制了并发性。独占锁只允许在同一时间只能有一个线程操作数据。

共享锁是一种乐观锁,放宽了加锁的条件,允许多个线程操作。

通过上面的描述可知独占锁&共享锁是一种锁的概念。

互斥锁&读写锁

每个对象都对应于一个可称为” 互斥锁” 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象(锁本身就是互斥的)。

读写锁适合用于读操作多的场景。拥有读锁的线程可以读资源,拥有写锁的线程可以写资源。

某线程对资源加读锁时,其他线程可以也可以加读锁,但是不能加写锁(也就是其他现在在读的时候,别的线程也只能读,不能写)。

某线程对资源加写锁时,其他线程什么锁都不能加(也就是线程在写的时候,其他线程什么都不能干)。

通过上面的描述可知互斥锁和读写锁是锁的具体实现。

可重入锁

当一个线程要获取被其他线程持有的独占锁时,该线程则被阻塞。如果不被阻塞,该锁是可重入的,也就是可重入锁。

一旦该线程获取到可重入的锁,它是可以无限次数的进入这个锁的。也就是说重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁。

底层原理维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。

通过上面的描述可知可重入锁是一种锁的设计。

偏向锁&轻量级锁&重量级锁

这是Java里面所涉及到的一些锁优化。在JDK1.6 为了减少获得锁和释放锁所带来的性能消耗,引入了4种锁的状态:无锁、偏向锁、轻量级锁和重量级锁,它会随着多个线程的竞争情况逐渐升级,但不能降级。

研究发现大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得。为了不让这个线程每次获得锁都需要CAS操作的性能消耗,就引入了偏向锁。当一个线程访问对象并获取锁时,会在对象头里存储锁偏向的这个线程的ID,以后该线程再访问该对象时只需判断对象头的Mark Word里是否有这个线程的ID,如果有就不需要进行CAS操作,这就是偏向锁。当线程竞争更激烈时,偏向锁就会升级为轻量级锁,轻量级锁认为虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式等待一会儿上一个线程就会释放锁,但是当自旋超过了一定次数或者一个线程持有锁,一个线程在自旋,又来了第三个线程访问时(竞争加大),轻量级锁就会膨胀(升级)为重量级锁,重量级锁就是Synchronized,重量级锁会使除了此时拥有锁的线程以外的线程都阻塞。

分布式锁

分布式锁是指在分布式的部署环境下,通过锁机制来让多客户端互斥的对共享资源进行访问。

目前现在互联网系统架构都是分布式部署的,能带来性能和效率上的提升。分布式锁是架构里面的一种。用来解决一个分布式环境下,数据一致性的问题。

当某个资源在多系统之间,具有共享性的时候,为了保证大家访问这个资源数据是一致的,那么就必须要求在同一时刻只能被一个客户端处理,不能并发的执行,否者就会出现同一时刻有人写有人读,大家访问到的数据就不一致。

在之前的单机时代,虽然不需要分布式锁,也面临过这种类似的问题,只不过在单机的情况下,如果有多个线程要同时访问某个共享资源的时候,我们可以采用线程间加锁的机制,即当某个线程获取到这个资源后,就立即对这个资源进行加锁,当使用完资源之后,再解锁,其它线程就可以接着使用了。在JAVA中就专门提供了处理锁机制的一些关键字及API(synchronize&Lock);

但到了分布式系统的时代,这种线程之间的锁机制,就没作用了,系统可能会有多份并且部署在不同的机器上,这些资源已经不是在线程之间共享了,而是属于进程之间共享的资源。因此,为了解决这个问题,就引入了分布式锁。

分布式锁要满足的要求:

排他性:在同一时间只会有一个客户端能获取到锁,其它客户端无法同时获取。

避免死锁:锁在一段有限的时间之后,一定会被释放(正常释放或异常释放)。

高可用:获取或释放锁的机制必须高可用且性能佳。

数据库锁

数据库锁也是为了保证数据的完整性和一致性。当多个线程并发访问某个数据时,加锁可以保证这个数据在任何时刻最多只有一个线程在访问。

在数据库中按照锁粒度划分,可以将锁划分成 行锁,页锁和表锁。

行锁:按照行的粒度对数据进行锁定,锁定粒度小,发生锁冲突概率低,可以实现并发都高,但是对于锁的开销比较大,加上会比较慢,容易出现死锁的情况。

页锁:是页的粒度上进行锁定,锁定的数据资源比行锁要多,因为一个页中可以有多个行记录,当我们使用页锁的时候,会出现数据浪费的现象,页锁的开销介于表锁行锁之间。

表锁:就是对数据进行锁定,锁定粒度很大,发送锁的概率很高,数据访问的并发度。不过好处在于对锁的使用开销小,加锁会很快。

【InnoDB 和 Oracle 支持行锁和表锁,MyISAM 只支持表锁, MYSQL BDB 存储引擎支持页锁和表锁。SQL Server 可以支持行锁,页锁和表锁】

如果按照数据库管理角度划分,可以将锁分为排他锁和共享锁。

共享锁:也叫读锁,或者 S 锁,共享锁锁定的资源可以被其他线程读取,但不能修改。 在进行 SELECT 的时候,会将对象进行共享锁锁定,当数据读取完毕之后,释放共享锁,这样就可以保证数据在读取时不被修改。

排他锁:也叫做独占锁,写锁或者 X 锁,排他锁锁定的数据只允许进行锁定操作的事务使用,其他事务无法对已锁定的数据进行查询或者修改。

从开发人员的角度来划分,又可以分为悲观锁和乐观锁。

悲观锁:适合写操作多的场景,因为写的操作具有排它性。采⽤悲观锁的⽅式,可以在数据库层⾯阻⽌其他事务对该数据的操作权限,防⽌读-写和写-写的冲突。

乐观锁:适合读操作多的场景,相对来说写的操作⽐较少。优点在于程序实现,不存在死锁问题,不过适⽤场景也会相对乐观,因为它阻⽌不了除了程序以外的数据库操作。



总的来说锁是系统&数据库中的一个非常重要的概念。



当多个线程同时对共享资源并发操作时,会导致的一些问题的发送。



所以锁主要用于多线程环境下保证共享资源的完整性和一致性。




版权声明:本文为weixin_46129103原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。