并发编程基础

线程与进程

基本概念:
进程是代码在数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,线程则是进程的一个执行路径,一个进程中至少有一个线程,进程中的多个线程共享进程的资源。
操作系统在分配资源时是把资源分配给进程的,但是 CPU 资源比较特殊,它是被分配到线程的,因为真正要占用 CPU 运行的是线程,所以也说线程是 CPU 分配的基本单位。

Q:为何要将程序计数器设计为线程私有的?

A:程序计数器是一块内存区域,用来记录线程当前要执行的指令地址。线程是占用CPU执行的基本单位,而CPU一般是使用时间片轮转方式让线程轮询占用的,所以当前线程CPU时间片用完后,要让出
CPU,等下次轮到自己的时候再执行。那么如何知道之前程序执行到哪里了呢?其实程序计数器就是为了记录该线程让出CPU时的执行地址的,待再次分配到时间片时线程就可以从自己私有的计数器指定地址继续执行。另外需要注意的是,如果执行的是native方法,那么pc计数器记录的是undefined地址,只有执行的是Java代码时pc计数器记录的才是下一条指令的地址。

Q:每个线程都有自己的栈资源吗,起什么作用?

A:每个线程都有自己的栈资源,用于存储该线程的局部变量,这些局部变量是该线程私有的,其他线程是访问不了的,除此之外栈还用来存放线程的调用栈帧

Q:堆和方法区分别存储什么?

A:堆是一个进程中最大的一块内存,堆是被进程中的所有线程共享的,是进程创建时分配的,堆里面主要存放使用 new 操作创建的对象实例。
方法区则用来存放 JVM 加载的类、常量及静态变量等信息,也是线程共享的。

Q:线程创建以及运行的几种方式

A:Java 中有三种线程创建方式,分别为:

  • 实现 Runnable 接口的 run 方法
//创建线程的方式  
Thread thread = new Thread(  
	new Runnable() {  
		@Override  
		public void run() {  
			System.out.println("Runnable方式创建的线程");  
		}  
	});  
thread.start();
  • 继承 Thread 类并重写 run 的方法
class MyThread extends Thread{  
	@Override  
	public void run() {  
		System.out.println("继承Thread类重写run方法方式创建的线程");  
	}  
}
public static void main(String[] args) {  
	MyThread myThread = new MyThread();  
	myThread.start();  
}
  • 使用 FutureTask
  
FutureTask<String> futureTask = new FutureTask<>(new Callable<String>() {  
	@Override  
	public String call() throws Exception {  
		return "FutureTask结合Callable方式创建的线程";  
	}  
});  
new Thread(futureTask).start();  
try {  
		String result = futureTask.get();  
		System.out.println(result);  
	} catch (InterruptedException | ExecutionException e) {  
		throw new RuntimeException(e);  
}
Q:当一个线程调用一个共享变量的 wait() 方法时会发生什么?

A:该调用线程会被阻塞挂起,直到发生下面几件事情之一才返回:
(1)其他线程调用了该共享对象的 notify() 或者 notifyAll() 方法;
(2)其他线程调用了该线程的 interrupt() 方法,该线程抛出 InterruptedException 异常返回。
另外需要注意的是,如果调用 wait() 方法的线程没有事先获取该对象的监视器锁,则调用 wait() 方法时调用线程会抛出 IllegalMonitorStateException 异常。

Q:一个线程如何才能获取一个共享变量的监视器锁?

A:有如下方式:

  1. 执行 synchronized 同步代码块时,使用该共享变量作为参数
synchronized(共享变量){  
	//doSomething  
}
  1. 调用该共享变量的方法,并且该方法使用了 synchronized 修饰
synchronized void function(int a,int b){
	//doSomething
}
Q:什么叫虚假唤醒?

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

Q:怎养避免虚假唤醒?

A:虚假唤醒在应用实践中很少发生,但要防患于未然,做法就是不停地去测试该线程被唤醒的条件是否满足,不满足则继续等待,也就是说在一个循环中调用 wait() 方法进行防范。退出循环的条件是满足了唤醒该线程的条件.

synchronized (obj) {
	while (条件不满足){
		obj.wait();
	}
}
Q: wait() 方法的超时时间起什么作用?

A:如果一个线程调用共享对象的该方法挂起后,没有在指定的 timeout ms 时间内被其他线程调用该共享变量的notify() 或者 notifyAll() 方法唤醒,那么该函数还是会因为超时而返回。如果将 timeout 设置为 0 则和 wait 方法效果一样,因为在 wait 方法内部就是调用了 wait(0)。需要注意的是,
如果在调用该函数时,传递了一个负的 timeout 则会抛出 IllegalArgumentException 异常

public final void wait(long timeout, int nanos) throws InterruptedException {  
	if (timeout < 0) {  
		throw new IllegalArgumentException("timeout value is negative");  
	}  
	  
	if (nanos < 0 || nanos > 999999) {  
		throw new IllegalArgumentException(  "nanosecond timeout value out of range");  
	}  
	  
	if (nanos > 0) {  
		timeout++;  
	}  
	  
	wait(timeout);  
}

其中的nanos参数猜测应该是原先的时候,需要在高并发的情况下,严格控制等待时间,但是当时限于技术优先,将其放进了TODO中,通过简单的方法先暂时留存在JDK中。
但是到了JDK后期版本,发现这个nanos其实毫无意义,但是为了兼容之前的版本,直接粗暴加1

Q: 被挂起的线程怎样被唤醒?

A:一个线程调用共享对象的 notify() 方法后,会唤醒一个在该共享变量上调用 wait 系列方法后被挂起的线程。一个共享变量上可能会有多个线程在等待,具体唤醒哪个等待的线程是随机的。notifyAll() 方法则会唤醒所有在该共享变量上由于调用 wait 系列方法而被挂起的线程。
被唤醒的线程不能马上从 wait 方法返回并继续执行,它必须在获取了共享对象的监视器锁后才可以返回 , 也就是唤醒它的线程释放了共享变量上的监视器锁后,被唤醒的线程也不一定会获取到共享对象的监视器锁,这是因为该线程还需要和其他线程一起竞争该锁,只有该线程竞争到了共享变量的监视器锁后才可以继续执行。

Q:如何实现等待线程执行终止?

A:Thread 类中有一个 join 方法就可以做这个事情,等待多个线程全部执行结束后在继续执行。join 是无参且返回值为 void 的方法。更好的方案: CountDownLatch

Q:Thread 类中的 sleep 方法有什么作用?

A:调用线程会暂时让出指定时间的执行权,也就是在这期间不参与 CPU 的调度,但是该线程所拥有的监视器资源,比如锁还是持有不让出的。指定的睡眠时间到了后该函数会正常返回,线程就处于就绪状态,然后参与 CPU 的调度,获取到 CPU 资源后就可以继续运行。

Q:Thread 类中的 yield 方法有什么作用?

A:当一个线程调用 yield 方法时,实际就是在暗示线程调度器当前线程请求让出自己的 CPU 使用,但是线程调度器可以无条件忽略这个暗示。当一个线程调用 yield 方法时,当前线程会让出 CPU 使用权,然后处于就绪状态,线程调度器会从线程就绪队列里面获取一个线程优先级最高的线程,当然也有可能会调度到刚刚让出CPU的那个线程来获取CPU执行权。
一般很少使用这个方法,在调试或者测试时这个方法或许可以帮助复现由于并发竞争条件导致的问题,其在设计并发控制时或许会有用途

Q:sleep 与 yield 方法的区别?

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

Q:简述Java 中的线程中断

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

Q:简述interrupt、isInterrupted、interrupted方法的区别

A:区别如下:

  • void interrupt() 方法 :中断线程,例如,当线程 A 运行时,线程 B 可以调用线程 A的 interrupt() 方法来设置线程 A 的中断标志为 true 并立即返回。设置标志仅仅是设置标志,线程 A 实际并没有被中断,它会继续往下执行。如果线程 A 因为调用了wait 系列函数、join 方法或者 sleep 方法而被阻塞挂起,这时候若线程 B 调用线程A 的 interrupt() 方法,线程 A 会在调用这些方法的地方抛出 InterruptedException 异常而返回。
  • boolean isInterrupted() 方法 :检测当前线程是否被中断,如果是返回 true,否则返回 false。
  • boolean interrupted() 方法 :检测当前线程是否被中断,如果是返回 true,否则返回 false。与 isInterrupted 不同的是,该方法如果发现当前线程被中断,则会清除中断标志,并且该方法是 static 方法,可以通过 Thread 类直接调用。
public static boolean interrupted() {  
	return currentThread().isInterrupted(true);  
}  
  
public boolean isInterrupted() {  
	return isInterrupted(false);  
}
/**  
* Tests if some Thread has been interrupted. The interrupted state  
* is reset or not based on the value of ClearInterrupted that is  
* passed.  
*/  
private native boolean isInterrupted(boolean ClearInterrupted);
**```
##### **Q:线程上下文切换的时机是什么?**
A:当前线程的 CPU 时间片使用完处于就绪状态时、当前线程被其他线程中断时。
##### **Q:什么是线程死锁?**
A:死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去
![image.png](https://file.likecourse.cn//blog/20230723094851.png)
##### **Q:线程死锁产生的四个条件?**
A:以下条件
- **互斥条件** :指线程对已经获取到的资源进行排它性使用,即该资源同时只由一个线程占用。如果此时还有其他线程请求获取该资源,则请求者只能等待,直至占有资源的线程释放该资源。
- **请求并持有条件**:指一个线程已经持有了至少一个资源,但又提出了新的资源请求,而新资源已被其他线程占有,所以当前线程会被阻塞,但阻塞的同时并不释放自己已经获取的资源。
- **不可剥夺条件** :指线程获取到的资源在自己使用完之前不能被其他线程抢占,只有在自己使用完毕后才由自己释放该资源。
- **环路等待条件** :指在发生死锁时,必然存在一个线程—资源的环形链,即线程集合{T0T1T2,…,Tn} 中的 T0 正在等待一个 T1 占用的资源,T1 正在等待 T2 占用的资源,……Tn 正在等待已被 T0 占用的资源。
##### **Q:如何避免线程死锁?**
A:破坏掉至少一个构造死锁的必要条件即可,但是目前只有请求并持有和环路等待条件是可以被破坏的
##### **Q:守护线程和用户线程有什么区别?**
A:区别如下:
- 当最后一个非守护线程结束时,JVM 会正常退出,而不管当前是否有守护线程,也就是说守护线程是否结束并不影响 JVM 的退出,言外之意,只要有一个用户线程还没结束,正常情况下 JVM 就不会退出
- 创建方式不同
```java
// 创建用户线程
Thread userThread = new Thread(new Runnable() {
    @Override
    public void run() {
        // 用户线程的任务
    }
});
userThread.start();

// 创建守护线程
Thread daemonThread = new Thread(new Runnable() {
    @Override
    public void run() {
        // 守护线程的任务
    }
});
daemonThread.setDaemon(true);
daemonThread.start();

Q:main线程结束后,其他子线程会发生什么?

A:要分两种情况

  1. 当子线程是用户线程时,main线程退出,若子线程中还有需要执行的操作(如循环),则JVM进程不会停止,还会继续运行,待子线程任务结束后JVM进程才会关闭。
  2. 当子线程是守护线程时,main线程退出,子线程不管有没有还需要执行的任务,JVM进程都会停止。
Q:JVM怎么实现上述描述的?

A:main 线程运行结束后,JVM 会自动启动一个叫作 DestroyJavaVM 的线程,该线程会等待所有用户线程结束后终止 JVM 进程,翻看 JVM 的代码:

int JNICALL
JavaMain(void * _args)
{
	...
	//执行Java中的main函数
	(*env)->CallStaticVoidMethod(env, mainClass, mainID, mainArgs);
	//main函数返回值
	ret = (*env)->ExceptionOccurred(env) == NULL ? 0 : 1;
	//等待所有非守护线程结束,然后销毁JVM进程
	LEAVE();
}

LEAVE 是 C 语言里面的一个宏定义,具体定义如下:

#define LEAVE() 
	do { 
		if ((*vm)->DetachCurrentThread(vm) != JNI_OK) { 
			JLI_ReportErrorMessage(JVM_ERROR2); 
			ret = 1; 
		} 
		if (JNI_TRUE) { 
			(*vm)->DestroyJavaVM(vm); 
			return ret; 
		} 
	} while (JNI_FALSE)
Q:什么是ThreadLocal,有什么作用?

A:多线程访问同一个共享变量时特别容易出现并发问题,特别是在多个线程需要对一个共享变量进行写入时。为了保证线程安全,一般使用者在访问共享变量时需要进行适当的同步
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dMuxakd4-1690168519656)(https://file.likecourse.cn//blog/20230723111351.png)]
同步的措施一般是加锁,这就需要使用者对锁有一定的了解,这显然加重了使用者的负担。那么有没有一种方式可以做到,当创建一个变量后,每个线程对其进行访问的时候访问的是自己线程的变量呢?其实 ThreadLocal 就可以做这件事情,虽然 ThreadLocal 并不是为了解决这个问题而出现的。
ThreadLocal 是 JDK 包提供的,它提供了线程本地变量,也就是如果你创建了一个ThreadLocal 变量,那么访问这个变量的每个线程都会有这个变量的一个本地副本。当多个线程操作这个变量时,实际操作的是自己本地内存里面的变量,从而避免了线程安全问题,避免线程间的数据竞争和同步问题。创建一个 ThreadLocal 变量后,每个线程都会复制一个变量到自己的本地内存,如下图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8lULJ5gI-1690168519657)(https://file.likecourse.cn//blog/20230723111625.png)]

Q:ThreadLocal怎么使用?

A:使用 ThreadLocal 的基本步骤:

  • 创建一个 ThreadLocal 对象并初始化:你需要先创建一个 ThreadLocal 对象,并使用其泛型来指定要存储的数据类型。例如,如果你要存储字符串类型的线程局部变量,可以创建ThreadLocal<String> 对象。
ThreadLocal<String> threadLocalVariable = new ThreadLocal<>();
  • 获取线程局部变量的值:通过 ThreadLocalget() 方法,你可以获取当前线程的局部变量的值。
String value = threadLocalVariable.get();
  • 清除线程局部变量的值(可选):在一些情况下,你可能需要手动清除线程局部变量的值。可以通过 ThreadLocalremove() 方法来实现。
threadLocalVariable.remove();

注意事项:

  • 每个线程在使用 ThreadLocal 存储的数据时,都是访问自己线程内部的副本,因此不会影响其他线程的数据。
  • 当线程终止后,由 ThreadLocal 存储的数据会被自动回收,避免了内存泄漏的问题。
  • ThreadLocal 不是用来解决线程间通信问题的工具,而是用来在每个线程中保持独立状态的工具。

请注意,在使用 ThreadLocal 时要小心,确保正确地设置和清除局部变量的值,避免造成数据泄露或不一致的问题。同时,要在适当的时候使用 remove() 方法清除线程局部变量的值,以免长时间运行的线程占用过多的内存。

Q:Thread 里面的 threadLocals 为何被设计为 map 结构?

A:Thread 类中的 threadLocals 被设计为 map 结构是为了支持同时在一个线程中存储多个不同的线程局部变量。使用 map 结构可以方便地根据不同的 ThreadLocal 实例来存储和获取对应的线程局部变量值,而无需为每个线程局部变量单独定义字段。
考虑以下场景,一个线程需要在不同的方法或类之间传递多个相关的变量,但这些变量对其他线程不可见,传统的做法是将这些变量作为参数传递给方法或构造函数。然而,如果有很多这样的变量,传递参数将变得非常繁琐。使用 ThreadLocal 可以避免这个问题,因为它允许我们在一个线程中存储多个线程局部变量,并且这些变量对其他线程是隔离的。
ThreadLocal 使用 map 结构是因为它需要在每个线程内部维护多个线程局部变量的映射关系。threadLocals 是一个 ThreadLocalMap 类型的成员变量,它是 ThreadLocal 的内部类,用于存储线程局部变量的键值对。ThreadLocalMap 是一个自定义的简单哈希表,它使用线性探测法解决哈希冲突。在 Thread 对象中使用 ThreadLocalMap 来存储和获取线程局部变量的值,其中 ThreadLocal 实例充当键,线程局部变量值充当值。
由于每个线程可以拥有多个不同的 ThreadLocal 实例,使用 map 结构可以高效地管理这些键值对。这样,不同的 ThreadLocal 实例之间不会相互干扰,每个线程可以独立地管理自己的线程局部变量。
总结:ThreadLocal 中的 threadLocals 被设计为 map 结构是为了在一个线程中支持存储和获取多个不同的线程局部变量,让不同的 ThreadLocal 实例之间互不干扰,并提供高效的键值对管理机制。

Q:ThreadLocal 是否支持继承性?

A:ThreadLocal 不支持继承性,同一个 ThreadLocal 变量在父线程中被设置值后,在子线程中是获取不到的

Q:怎样使子线程能访问到父线程中的值?

A:InheritableThreadLocal继承自 ThreadLocal,其提供了一个特性,就是让子线程可以访问在父线程中设置的本地变量。 下面看一下 InheritableThreadLocal 的代码

public class InheritableThreadLocal<T> extends ThreadLocal<T> {  

	protected T childValue(T parentValue) {  
		return parentValue;  
	}  
	  
	ThreadLocalMap getMap(Thread t) {  
		return t.inheritableThreadLocals;  
	}  
  
	void createMap(Thread t, T firstValue) {  
		t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);  
	}  
}

实现原理是子线程是通过在父线程中通过调用new Thread()方法来创建子线程,Thread#init方法在Thread的构造方法中被调用。在init方法中拷贝父线程数据到子线程中:

private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc,
                      boolean inheritThreadLocals) {
    if (name == null) {
        throw new NullPointerException("name cannot be null");
    }

    if (inheritThreadLocals && parent.inheritableThreadLocals != null)
        this.inheritableThreadLocals =
            ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
    this.stackSize = stackSize;
    tid = nextThreadID();
}

InheritableThreadLocal仍然有缺陷,一般我们做异步化处理都是使用的线程池,而InheritableThreadLocal是在new Thread中的init()方法给赋值的,而线程池是线程复用的逻辑,所以这里会存在问题。
当然,有问题出现就会有解决问题的方案,阿里巴巴开源了一个TransmittableThreadLocal组件就可以解决这个问题。

多线程并发编程

基本概念:
并发是指同一个时间段内多个任务同时都在执行,并且都没有执行结束
并行是说在单位时间内多个任务同时在执行
并发任务强调在一个时间段内同时执行,而一个时间段由多个单位时间累积而成,所以说并发的多个任务在单位时间内不一定同时在执行

Q:多线程下处理共享变量时 Java 的内存模型?

A:Java 内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存,线程读写变量时操作的是自己工作内存中的变量
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NxnYRJgV-1690168519659)(https://file.likecourse.cn//blog/20230723153920.png)]
Java 内存模型是一个抽象的概念,在实际实现中线程的工作内存是下图所示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zrj6YxbV-1690168519664)(https://file.likecourse.cn//blog/20230723154330.png)]
图中所示是一个双核 CPU 系统架构,每个核有自己的控制器和运算器,其中控制器包含一组寄存器和操作控制器,运算器执行算术逻辑运算。每个核都有自己的一级缓存,在有些架构里面还有一个所有 CPU 都共享的二级缓存。那么 Java 内存模型里面的工作内存,就对应这里的 L1 或者 L2 缓存或者 CPU 的寄存器。

Q:什么是内存不可见问题?

A:内存不可见问题(Memory Visibility Problem),也称为内存一致性问题(Memory Consistency Problem),是多线程编程中的一个重要概念。
在多核处理器或多线程环境中,每个线程都有自己的线程缓存(Thread Cache),线程可以在自己的缓存中读取和写入变量。然而,当多个线程同时修改共享的变量时,可能会导致一个线程对变量的修改对其他线程是不可见的。这就是内存不可见问题。
内存不可见问题会导致以下情况:

  1. 线程之间无法正确共享数据:当一个线程修改了共享变量的值,其他线程无法立即感知到这个修改。这可能导致其他线程读取过期的数据,从而导致程序出现错误。
  2. 数据的重排序:在多核处理器或优化编译器中,为了提高性能,会对指令进行重排序。这可能导致变量的读写顺序与代码中的顺序不一致,进而影响程序的正确性。
    为了解决内存不可见问题,需要使用同步机制来确保多个线程之间对共享变量的访问是有序的。常见的同步机制包括使用 synchronized 关键字、volatile 关键字、Lock 接口以及原子类(Atomic classes)等。这些同步机制能够保证线程之间的可见性和顺序性,从而避免内存不可见问题。
Q:简述一下synchronized关键字?

A:synchronized 块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用,这些 Java 内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。线程的执行代码在进入 synchronized 代码块前会自动获取内部锁,这时候其他线程访问该同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后或者在同步块内调用了该内置锁资源的 wait 系列方法时释放该内置锁。内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。synchronized的使用需要从用户态切换到内核态执行阻塞操作执行上下文切换,是一个耗时的操作。

Q:synchronized 的内存语义以及其作用?

A:进入 synchronized 块的内存语义是把在 synchronized 块内使用到的变量从线程的工作内存中清除,这样在 synchronized 块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。退出 synchronized 块的内存语义是把在 synchronized 块内对共享变量的修改刷新到主内存。这也是加锁和释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本地内存中修改的共享变量刷新到主内存

Q:简述一下volatile关键字?

A:volatile 是 Java 中用于修饰变量的关键字。当一个变量被声明为 volatile 时,意味着这个变量的值可能会被多个线程同时修改和访问,而且编译器和处理器会被要求执行特定的内存可见性操作,以确保线程之间对该变量的读取和写入是正确的,避免了内存不可见问题。
主要特点和作用

  1. 内存可见性:使用 volatile 修饰的变量在被一个线程修改后,会立即将修改的值刷新到主内存,同时通知其他线程,从而保证其他线程在访问该变量时能获取到最新的值。
  2. 禁止指令重排序:volatile 修饰的变量在读写时,会禁止编译器和处理器对其相关操作进行重排序,保证操作的有序性,避免了出现不符合预期的结果。
  3. 不保证原子性:虽然 volatile 可以保证对变量的读写操作的可见性和有序性,但它并不能保证复合操作的原子性。比如 i++ 这种自增操作并不是原子操作,多个线程同时执行自增操作可能会导致结果不正确。
    适用场景:
  • volatile 适用于对共享变量的频繁读取和修改操作,而且修改操作不依赖于当前值的场景。
  • 适用于状态标志等需要及时通知其他线程的场景。
    需要注意的是,volatile 并不是解决所有多线程问题的万能钥匙,它只能解决特定的问题。在复杂的多线程场景中,可能需要使用更复杂的同步机制,比如 synchronizedLock、原子类等,来确保线程安全和正确性。
Q:简述CAS操作?

A:CAS 即Compare and Swap,其是 JDK 提供的非阻塞原子性操作,它通过硬件保证了比较—更新操作的原子性。JDK 里面的 Unsafe 类提供了一系列的compareAndSwap* 方法,以compareAndSwapLong为例:

boolean compareAndSwapLong(Object obj,long valueOffset,long expect, long update)

compareAndSwapLong方法是一种原子性指令,用于实现比较并交换(CAS)操作。它有四个操作数,分别是:

  1. Object obj: 这是目标对象,即包含要进行操作的long变量的对象。CAS操作将在该对象上执行。
  2. long valueOffset: 这是long变量在对象obj中的内存偏移量(以字节为单位)。它指示了该变量在对象内存中的位置。
  3. long expect: 这是long变量的预期值。只有当实际值与指定的valueOffset处的值匹配expect时,CAS操作才会成功。
  4. long update: 这是新的值,如果CAS操作成功(即实际值与expect匹配),那么该值将替换旧值。

CAS操作的意义是:在并发环境下,多个线程可能同时尝试修改同一个变量的值。使用传统的非原子性操作可能导致竞态条件,即多个线程之间的执行顺序和时序会影响最终结果,导致数据不一致或出现错误。CAS操作通过提供原子性的读取和更新步骤,避免了竞态条件,确保了线程安全性。

具体CAS操作流程如下:

  1. 读取对象objvalueOffset处的long变量的当前值。
  2. 检查读取的值是否等于expect,如果相等,则表示当前变量值符合预期,可以进行更新。
  3. 如果读取的值与expect相等,使用update的值来替换valueOffset处的变量值。
  4. 如果读取的值与expect不相等,说明其他线程已经修改了变量的值,CAS操作失败,不进行更新。
    整个CAS操作是原子性的,如果多个线程同时执行CAS操作,只有一个线程的CAS操作会成功,其他线程的CAS操作会失败。通过这种方式,CAS保证了变量的更新操作在并发情况下的正确性和一致性。
Q:简述CAS的ABA问题?

A:假如线程 I 使用 CAS 修改初始值为 A 的变量 X,那么线程 I 会首先去获取当前变量 X 的值(为 A),然后使用 CAS 操作尝试修改 X 的值为 B,如果使用 CAS 操作成功了,那么程序运行一定是正确的吗?其实未必,这是因为有可能在线程 I 获取变量 X 的值 A 后,在执行 CAS 前,线程 II 使用 CAS 修改了变量 X 的值为 B,然后又使用 CAS 修改了变量 X 的值为 A。所以虽然线程 I 执行 CAS时 X 的值是 A,但是这个 A 已经不是线程 I 获取时的 A 了。
ABA 问题的产生是因为变量的状态值产生了环形转换,就是变量的值可以从 A 到 B,然后再从 B 到 A。如果变量的值只能朝着一个方向转换,比如 A 到 B,B 到 C,不构成环形,就不会存在问题。JDK中的 AtomicStampedReference 类给每个变量的状态值都配备了一个时间戳,从而避免了 ABA 问题的产生。

Q:什么是指令重排序?

A:指令重排序是现代处理器在执行指令时的一种优化技术。为了提高程序执行效率,处理器可能会重新安排指令的执行顺序,但保证最终的执行结果与不进行重排序时一致

Q:在Java中如何避免因为指令重排序引起的问题?

A:可能导致多线程程序在并发环境下出现一些意料之外的问题。为了避免这些问题,Java提供了内存模型(Java Memory Model,JMM)来规定多线程程序的行为,保证了可见性、有序性和原子性的特性。在Java中,使用volatile关键字、synchronized关键字以及java.util.concurrent包下的并发类,可以避免大部分因指令重排序引起的问题,确保多线程程序的正确执行。

Q:简述一下内存屏障和内存栅栏?

A:内存屏障(Memory Barrier)和内存栅栏(Memory Fence)是在并发编程中用于控制内存访问和指令重排序的同步指令。它们的作用是确保在处理器执行的指令序列中,特定内存操作的顺序和可见性,以保证多线程程序的正确性和一致性。

  1. 内存屏障(Memory Barrier):也称为屏障指令或同步指令,用于防止指令重排序和优化对内存的访问。在编写高级语言代码时,为了提高性能,编译器和处理器可能会对指令进行重排。内存屏障指令在特定位置插入屏障,强制让处理器在屏障前的指令完成后再执行屏障后的指令,从而避免了重排序带来的问题。内存屏障有读屏障和写屏障两种类型。

内存屏障可以分为两种类型:读屏障和写屏障。
读屏障(Load Barrier):它确保在读操作之前,所有对共享数据的写操作都完成。换句话说,它保证在读取共享数据之前,所有之前的写操作都已经对其他线程可见。
写屏障(Store Barrier):它确保在写操作之后,所有对共享数据的写操作和写入缓存的操作都已完成。这样可以保证在写入共享数据之后,其他线程在读取该数据时能看到最新的写入结果。

在Java中,内存屏障的功能被封装在volatile关键字和synchronized关键字中,它们都具有禁止指令重排序和保证内存可见性的特性。此外,在Java 1.5之后,还引入了java.util.concurrent包,其中提供了更多的并发类,如AtomicIntegerAtomicLong等,这些类使用了底层的内存屏障来保证线程安全性。

  1. 内存栅栏(Memory Fence):也称为内存栅,是一种更严格的内存屏障。内存栅栏保证了所有在栅栏之前的内存操作都完成,并且栅栏之后的内存操作不会在栅栏之前的内存操作之前执行。这意味着在内存栅栏之前的指令的结果对于栅栏之后的指令是可见的。内存栅栏是一种比内存屏障更强的同步机制。
Q:什么是伪共享?

A:为了解决计算机系统中主内存与 CPU 之间运行速度差问题,会在 CPU 与主内存之间添加一级或者多级高速缓冲存储器(Cache)。这个Cache 一般是被集成到 CPU 内部的,所以也叫 CPU Cache。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lf4EizVC-1690168519665)(https://file.likecourse.cn//blog/20230724100220.png)]
在 Cache 内部是按行存储的,其中每一行称为一个 Cache 行。Cache 行是 Cache 与主内存进行数据交换的单位,Cache 行的大小一般为 2 的幂次数字节。
CPU 访问某个变量时,首先会去看 CPU Cache 内是否有该变量,如果有则直接从中获取,否则就去主内存里面获取该变量,然后把该变量所在内存区域的一个 Cache行大小的内存复制到 Cache 中。由于存放Cache 行的是内存块而不是单个变量,把多个变量存放到一个 Cache 行中。当多个线程同时修改一个缓存行里面的多个变量时,由于同时只能有一个线程操作缓存行,所以相比将每个变量放到一个缓存行,性能会有所
下降,这就是伪共享

Q:如何避免伪共享?

在 JDK 8 之前一般都是通过字节填充的方式来避免该问题,也就是创建一个变量时使用填充字段填充该变量所在的缓存行,这样就避免了将多个变量存放在同一个缓存行中。JDK8中提供注解@Contended,需要注意的是,在默认情况下,@Contended 注解只用于 Java 核心类,比如 rt 包下的类。如果用户类路径下的类需要使用这个注解,则需要添加 JVM 参数:-XX:-RestrictContended。填充的宽度默认为 128,要自定义宽度则可以设置 -XX:ContendedPaddingWidth 参数。

Q:乐观锁与悲观锁?

乐观锁和悲观锁是并发编程中常用的两种锁策略,用于解决多线程环境下的数据竞争和数据一致性问题。

  1. 乐观锁:
    乐观锁是一种乐观的假设,认为多个线程之间很少发生冲突,因此不会阻塞线程,而是在进行数据更新时,先进行操作,然后检查是否有其他线程同时修改了数据。如果没有发现冲突,那么操作就成功了;如果发现有冲突,就进行回滚或重试。乐观锁通常使用版本号或时间戳等机制来辅助检查数据是否发生过变化。
    在数据库中,乐观锁常见的实现方式是使用版本号或时间戳列来辅助。在Java中,乐观锁可以通过CAS(Compare-and-Swap)操作实现,例如使用AtomicIntegerAtomicLong类。

  2. 悲观锁:
    悲观锁是一种悲观的假设,认为多个线程之间会经常发生冲突,因此在进行数据操作时,会将数据加锁,阻塞其他线程的访问,直到当前线程完成操作。悲观锁适用于并发冲突较多的情况,可以确保数据的安全性,但也会带来较大的性能开销。
    在数据库中,悲观锁常见的实现方式是使用排他锁(Exclusive Lock)或共享锁(Shared Lock),这取决于具体的业务需求。在Java中,悲观锁可以通过synchronized关键字或ReentrantLock类来实现。

选择乐观锁还是悲观锁取决于具体的应用场景和并发访问的情况。通常情况下,如果并发冲突较少,乐观锁是一个较好的选择,它不会阻塞线程,性能较高;而如果并发冲突较多,悲观锁可以确保数据的一致性和安全性,但性能较低。因此,需要根据具体的业务需求和并发情况来选择合适的锁策略。

Q:公平锁与非公平锁?

根据线程获取锁的抢占机制,锁可以分为公平锁和非公平锁,公平锁表示线程获取锁的顺序是按照线程请求锁的时间早晚来决定的,也就是最早请求锁的线程将最早获取到锁。而非公平锁则在运行时闯入,也就是先来不一定先得。
ReentrantLock 提供了公平和非公平锁的实现。

公平锁 :ReentrantLock pairLock = new ReentrantLock(true)
非公平锁 :ReentrantLock pairLock = new ReentrantLock(false)

如果构造函数不传递参数,则默认是非公平锁。在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来性能开销.

Q:独占锁与共享锁?

根据锁只能被单个线程持有还是能被多个线程共同持有,锁可以分为独占锁和共享锁。

独占锁保证任何时候都只有一个线程能得到锁,ReentrantLock 就是以独占方式实现的。
共享锁则可以同时由多个线程持有,例如ReadWriteLock 读写锁,它允许一个资源可以被多线程同时进行读操作。

独占锁是一种悲观锁,由于每次访问资源都先加上互斥锁,这限制了并发性,因为读操作并不会影响数据的一致性,而独占锁只允许在同一时间由一个线程读取数据,其他线程必须等待当前线程释放锁才能进行读取。
共享锁则是一种乐观锁,它放宽了加锁的条件,允许多个线程同时进行读操作。

Q:什么是可重入锁?

可重入锁(Reentrant Lock),也称为递归锁,是一种特殊类型的锁,允许同一个线程多次获得该锁而不会造成死锁。在可重入锁的设计中,锁持有计数器记录了线程对锁的获得次数,每次成功获取锁时,计数器会加一,释放锁时计数器会减一,直到计数器为零时,其他线程才能获得该锁。
可重入锁的特性使得一个线程可以重复获取自己已经持有的锁,而不会被阻塞。这种机制使得编写复杂的递归代码或涉及到多个层次的方法调用更加方便,因为在递归调用过程中,同一个线程可以多次获取锁而不会出现死锁。
在Java中,ReentrantLock类就是可重入锁的一个实现。使用ReentrantLock,可以在代码中显示地获取和释放锁,并且允许同一个线程多次获取锁。当一个线程多次获取锁时,必须相应地释放相同次数的锁,否则其他线程无法获取该锁。
示例代码如下:

import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {
    private static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) {
        // 第一次获取锁
        lock.lock();
        try {
            System.out.println("First lock acquired.");
            // 第二次获取锁,仍然成功
            lock.lock();
            try {
                System.out.println("Second lock acquired.");
                // 执行一些操作
            } finally {
                // 释放第二次获取的锁
                lock.unlock();
                System.out.println("Second lock released.");
            }
        } finally {
            // 释放第一次获取的锁
            lock.unlock();
            System.out.println("First lock released.");
        }
    }
}

在上面的示例中,使用了ReentrantLock来实现可重入锁。首先,线程获取了锁两次,并在第二次获取锁后执行一些操作,最后按照获取锁的逆序释放锁。注意,释放锁的次数要和获取锁的次数匹配,否则会导致不正确的锁状态。

Q:什么是自旋锁?

当前线程在获取锁时,如果发现锁已经被其他线程占有,它不马上阻塞自己,在不放弃 CPU 使用权的情况下,多次尝试获取(默认次数是 10,可
以使用 -XX:PreBlockSpinsh 参数设置该值),很有可能在后面几次尝试中其他线程已经释放了锁。
如果尝试指定的次数后仍没有获取到锁则当前线程才会被阻塞挂起。由此看来自旋锁是使用 CPU 时间换取线程阻塞与调度的开销,但是很有可能这些 CPU 时间白白浪费了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值