0%

并发编程线程基础

线程

线程创建:

1、实现Runnable接口的run方法

2、继承Thread类并重写run的方法

3、使用FutureTask方式

继承Thread类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ThreadTest{

public static class MyThread extends Thread{

@Override
public void run(){
System.out.println("I am a child thread");
}

public static void main(String[] args){
//创建线程
MyThread thread = new MyThread();

//启动线程
thread.start();
}
}
}

调用start方法后,线程处于就绪状态。就绪状态指的是该线程已经获取了除CPU资源外的其他资源,等待获取CPU资源后才会真正处于运行状态。一旦run方法执行完毕线程就处于终止状态。

使用继承方法

好处:在重写方法内获取当前进程直接使用this就可以了,无需使用Thread.currentThread()方法

缺点:Java不支持多继承,继承了Thread类就不能再继承其他类;任务与代码没有分离,多个线程执行同一个任务时需要多份任务代码。

实现Runnable接口的run方法

1
2
3
4
5
6
7
8
9
10
11
12
13
public static class RunableTask implements Runnable{

@Override
public void run(){
System.out.println("I am a child thread");
}
}

public static void main(String[] args) throws InterruptedException{
RunableTask task = new RunableTask();
new Thread(task).start();
new Thread(task).start();
}

可以给RunableTask添加参数进行任务区分。

上述两个方式都有一个缺点:任务没有返回值。

使用FutureTask

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//创建任务类
public static class CallerTask implements Callable<String>{

@Override
public String call() throws Exception{
return "hello";
}

public static void main(String[] args) throws InterruptedException{
//创建异步任务
FutureTask<String> futureTask = new FutureTask<>(new CallerTask());
//启动线程
new Thread(futureTask).start();
try{
//等待任务执行完毕,并返回结果
String result = futureTask.get();
System.out.println(result);
}catch(ExecutionException e){
e.printStackTrace();
}
}
}

小结

使用继承方式的好处是方便传参,可以在子类里面添加成员变量,通过set方法设置参数或者通过构造函数进行传递,如果使用Runnable方式,就只能使用主线程里面声明为final的变量。

只有Futuretask方式可以拿到任务的返回结果。

线程通知和等待

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

线程获取一个共享变量的监视器锁的方法:

1、执行synchronized同步代码块时使用该共享变量作为参数

1
2
3
synchronized(共享变量){
//do something
}

2、调用该共享变量的方法,并且该方法使用了synchronized修饰

1
2
3
synchronized void add(int a, int b){
//do something
}

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

在应用中应该避免虚假唤醒——不停地测试该线程被唤醒地条件是否满足,不满足就继续等待(在一个循环中调用wait()方法)

1
2
3
4
5
6
synchronized(obj){
while(条件不满足){
obj.wait();
}
}
//首先通过同步块获取obj上面的监视器锁

比如生产者和消费者的例子,如果当前队列没有空闲容量就会调用共享变量queue的wait()方法挂起当前线程,循环避免虚假唤醒。假如当前线程被虚假唤醒了,但队列还是没有空闲余量,那么当前线程还是会调用wait()方法把自己挂起。

调用共享变量的wait()方法后会释放当前共享变量上的锁(为了打破死锁必要条件之一的持有并等待原则)。并且,当线程调用共享对象的wait()方法时,当前线程只会释放当前共享对象的锁,当前线程持有的其他共享对象的监视器锁并不会释放。

当一个线程调用wait()方法被阻塞挂起后,如果其他线程中断了该线程,则该线程会抛出InterruptedException异常并返回。

join()方法用来等待线程执行终止,线程A调用线程B的join方法会后会被阻塞。

sleep方法用来让线程睡眠。调用线程会暂时让出指定时间的执行权,在这期间内不参与CPU的调度,但是该线程拥有的监视器资源,比如锁还是持有不让出。

yield方法用来让出CPU执行权。当一个线程调用yield方法时,实际上是在暗示线程调度器当前线程请求让出自己的CPU使用,但是线程调度器可以无条件忽略这个暗示。(自己占有的时间片中还没有使用完的部分不想使用了)

然后当前线程会让出CPU使用权,处于就绪状态,线程调度器会从线程就绪队列中获取一个线程优先级最高的线程。

sleep方法和yield方法的区别在于,当线程调用sleep方法时调用线程会被阻塞挂起指定的时间,在这期间线程调度器不会调度该线程。

而调用yield方法时,线程只是让出自己剩余的时间片,并没有被阻塞挂起,而是处于就绪状态,线程调度器下一次调度时就有可能调度到当前线程执行。

线程中断

是一种线程间的协作模式,通过设置线程的中断标志并不能直接终止该线程的执行,而是被中断的线程根据中断状态自行处理。

1
2
3
void interrupt() 设置中断标志
boolean isInterrupted() 检测当前线程是否被中断
boolean interrupted() 检测当前线程是否被中断,如果发现当前线程被中断,则清除中断标志。可以通过Thread类直接调用

守护线程与用户线程

在Tomcat的NIO实现NioEndpoint中会开启一组接受线程来接受用户的连接请求,以及一组处理线程负责具体处理用户请求。

默认情况下,接受线程和处理线程都是守护线程,这意味着tomcat收到shutdown命令后并且没有其他用户线程存在的情况下tomcat进程会马上消亡,而不会等待线程处理完当前的请求。

如果你希望在主线程结束后JVM进程马上结束,那么在创建线程时可以将其设置为守护线程,如果希望在主线程结束后子线程继续工作,等子线程结束后再让JVM进程结束,那么就将子线程设置为用户线程。

ThreadLocal

JDK提供的,提供了线程本地变量,也就是如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题。

1
2
//创建ThreadLocal变量
static ThreadLocal<String> localVariable = new ThreadLocal<>();

8daf3d213d6fbcadc0bd7323ea94729a_720

实现原理

每个线程的本地变量不是存放在ThreadLocal实例里面,而是存放在调用线程的threadLocals变量里面。ThreadLocal类型的本地变量存放在具体的线程内存空间中,ThreadLocal就是一个工具壳,通过set方法把value值放入调用线程的threadLocals里面并存放起来,当调用线程调用它的get方法时,再从当前线程的threadLocals变量里将其拿出来使用。

threadLocals是一个HashMap结构,key就是当前ThreadLocal的实例对象引用,value是通过set方法传递的值。

如果当前线程一直不消亡,那么这些本地变量会一直存在,所以可能会造成内存溢出,因此使用完毕后要记得调用ThreadLocal的remove方法删除对应线程的threadLocals中的本地变量。

ThreadLocal不支持继承性

子线程中获取不到父线程中设置值的同一个ThreadLocal变量。为了解决这个,就产生了InheritableThreadLocal类。

InheritableThreadLocal类通过重写getMap和createMap让本地变量保存到了具体线程的inheritableThreadLocals变量里面,那么线程在通过InheritableThreadLocal类实例的set或者get方法设置变量时,就会创建当前线程的inheritableThreadLocals变量。当父线程创建子线程时,构造函数会把父线程中inheritableThreadLocals变量里面的本地变量复制一份到子线程的inheritableThreadLocals变量里面。

并发编程的其他基础知识

区分并发和并行

并发:同一个时间段内多个任务同时都在执行,并且都没有执行结束。

并行:单位时间内多个任务同时在执行。

并发的多个任务在单位时间内不一定同时在执行。比如双CPU配置,线程A和线程B各自在自己的CPU上执行任务,实现了真正的并行运行。在多线程编程实践中,线程的个数往往多于CPU的个数,所以一般都称多线程并发编程而不是多线程并行编程。

随着对应用系统性能和吞吐量要求的提高,出现了处理海量数据和请求的要求,这些都对高并发编程有着迫切的需求。

synchronized关键字

synchronized块是Java提供的一种原子性内置锁,Java中的每一个对象都可以把它当作一个同步锁来使用。

Java内置的,使用者看不到的锁被称为内部锁,也叫做监视器锁。

线程的执行代码在进入synchronized代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者在同步块内调用了该内置锁资源的wait系列方法时释放该内置锁。内置锁是排它锁。

排它锁:当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。

注意:Java中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而synchronized的使用就会导致上下文切换。

synchronized的内存语义

进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除,这样在synchronized块内使用到该变量时就不会从线程工作内存中获取,而是直接从主内存中获取。这样就解决了共享变量内存可见性的问题。

退出synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存。

synchronized也经常被用来实现原子性操作。

原子性操作是指一个操作或者一系列操作要么全部执行,要么全部不执行,不会出现执行了一部分的情况。
在计算机科学中,原子性操作通常用于多线程或分布式系统中,以确保数据的一致性和可靠性。例如,在数据库事务中,一组操作(如插入、更新和删除)被视为一个原子性操作,要么全部成功提交,要么全部回滚,以保证数据库的完整性。
原子性操作的实现方式有多种,常见的包括使用锁、事务机制、硬件指令等。这些方法可以确保在操作执行过程中,不会被其他线程或进程干扰,从而保证操作的原子性。

注意:synchronized会引起线程上下文切换并带来线程调度开销。

1
2
3
4
5
6
7
8
9
public class ThreadSafeInteger{
private int value;
public synchronized int get(){
return value;
}
public synchronized void set(int value){
this.value = value;
}
}

volatile关键字

使用锁太笨重,因为会带来线程上下文的切换开销。

对于解决内存可见性问题,Java还提供了一种弱形式的同步:使用volatile关键字。可以确保对一个变量的更新对其他线程马上可见。因为当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。而当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存中的值。

当线程写入了volatile变量值时就等价于线程退出synchronized同步块,读取volatile变量值时就相当于进入同步块。

1
2
3
4
5
6
7
8
9
public class ThreadSafeInteger{
private volatile int value;
public int get(){
return value;
}
public void set(int value){
this.value = value;
}
}

相比之下,synchronized方式是独占锁,同时只能有一个线程调用get()方法,其他调用线程都会被阻塞,同时会存在线程上下文切换和线程重新调度的开销。 使用volatile的方式是非阻塞算法,不会造成线程上下文切换的开销。

但volatile虽然提供了可见性支持,却并不保证操作的原子性。

一般使用volatile关键字的场景:

  • 写入变量值不依赖变量的当前值时。因为如果依赖当前值,将会是获取-计算-写入三步操作,这三步操作不是原子性的,而volatile不保证原子性。
  • 读写变量值时没有加锁。因为加锁本身已经保证了内存可见性,不需要volatile。

Java中的CAS操作

加了关键字synchronized后,同一时间就只能有一个线程可以调用,显然大大降低了并发性。

CAS即Compare and Swap,其是JDK提供的非阻塞原子性操作,通过硬件保证了比较-更新操作的原子性。

  • boolean compareAndSwapLong(Object obj, long valueOffset, long except, long update) 方法。四个操作数分别为:对象内存位置、对象中的变量的偏移量、变量预期值和新的值。操作含义:如果对象obj中内存偏移量为valueOffset的变量值为expect,则使用新的值update替换旧的值expect。这是处理器提供的一个原子性指令。

ABA问题:变量的状态值发生了环形转换,就是变量的值可以从A到B,然后再从B到A。如果变量的值只能朝着一个方向转换,比如从A到B,B到C,不构成环形,就不会存在问题。

JDK中的AtomicStampedReference类给每个变量的状态值都配备了一个时间戳,从而避免了ABA问题的产生。

乐观锁与悲观锁

悲观锁:对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态。悲观锁的实现往往依靠数据库提供的锁机制。

1
2
3
4
5
6
7
8
9
10
11
12
public int updateEntry(long id){
//使用悲观锁获取指定记录
EntryObject entry = query("select * from table1 where id = #{id} for update", id);

//修改记录内容,根据计算修改entry记录的属性
String name = generatorName(entry);
entry.setName(name);

//update操作
int count = update("update table1 set name=#{name}, age#{age} where id = #{id}", entry);
return count;
}

假设updateEntry、query、update方法都使用事务切面的方法,并且事务传播性被设置为required。

事务切面是面向切面编程(AOP)中的一个概念,主要用于处理事务管理相关的问题。

在软件应用中,事务通常指的是一组操作,这些操作要么全部成功执行,要么全部回滚,以保证数据的一致性。事务切面就是围绕事务处理这个关注点所创建的一个切面。

执行updateEntry方法时如果上层调用方法没有开启事务,则会即时开启一个事务,又因为事务传播性是required,所以执行query时不会开启新的事务,而是加入updateEntry开启的事务,也就是在updateEnrty方法执行完毕提交事务时,query方法才会被提交,记录的锁会持续到updateEntry执行结束。

当多个线程调用updateEntry的事务,并且传递的是同一个id时,只有一个线程执行获取指定记录会成功,因为同一时间只有一个线程可以获取对应记录的锁,在获取锁的线程释放锁前(updateEntry执行完毕,提交事务前),其他线程必须等待,也就是同一时间只有一个线程可以对该记录进行修改。

乐观锁:认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排它锁,而是在进行数据提交更新时,才会正式对数据冲突与否进行检测。

乐观锁并不会使用数据库提供的锁机制,一般在表中添加version字段或者使用业务状态来实现。乐观锁直到提交时才锁定,所以不会产生任何死锁。