Java多线程——常见面试题汇总

 

目录

1、并发与并行

2、线程与进程

3、线程的生命周期

4、守护线程

5、如何创建线程

6、多线程对性能的影响

7、run()方法与start()方法有什么区别

8、sleep()方法与wait()方法有什么区别

9、sleep()方法与yeild()方法的区别

10、终止线程的方法有哪些

11、线程安全性

12、Volatile关键字

13、ThreadLocal类的设计理念与作用

14、同步与异步

15、Synchronized和Lock

16、读-写锁ReadWriteLock

17、加锁会带来那些性能问题?如何解决?

18、并发容器

19、阻塞队列

20、生产者-消费者模式

21、同步工具类(闭锁、信号量、栅栏)

22、乐观锁与悲观锁

23、CAS

24、ThreadPool

25、造成死锁的原因及其预防

26、AQS

27、自旋锁、偏向锁、轻量级锁


 

1、并发与并行

  • 并行指多个处理器或者多核的处理器同时处理多个不同任务。

具体来说,并行是指在同一时刻,有多条指令在多个处理器上同时执行。做个比喻,将任务执行比喻为往车上装煤,而任务数量比作煤堆数,并行的意思就是,每个煤堆站一个人(相当于CPU)同时往车上装煤。

  • 并发:指一个处理器“同时”处理多个任务。

一个处理器同一时刻只能执行一个任务,并发的意思是处理器用很短的时间执行一个任务后切换到其他任务,如此循环的执行,当每个任务执行时间非常短,即切换很快的时候,在人的感知上就会感觉每个任务是同时进行的。再拿上边装煤举例:就是一个人往车上装煤,每次从一个煤堆取一点倒到车上,一直从1号煤堆取到最后一个如此循环,当他能取得特别快的时候,你就会看到这些煤堆是同时再减少的。

2、线程与进程

  • 进程是资源分配的基本单元。
  • 线程是独立调度的基本单元。一个进程中可以有多个线程,它们共享进程的资源。

进程vs线程:

  1. 占有资源:进程有自己对立的地址空间,每启动一个进程,系统都会为器分配地址空间,线程没有独立的地址空间,它共享进程的空间。
  2. 调度:同一进程中,线程的切换不会引起进程的切换,在不同的进程中线程的切换则会引起进程的切换。
  3. 系统开销:创建和撤销进程时,系统都要为之分配和回收资源,如内存空间,IO设备等。同样,在进程切换的时候,涉及到当前进程环境的保存和新进程环境的设置,而线程则与只需要保存和设置少量的寄存器即可。因此,与进程相比,线程的系统开销更小。
  4. 通信:进程间通信需要借助IPC,而线程间可以直接读/写进程的数据段进行通信。
  5. 安全:多进程程序更加安全,生命力更强,一个进程死掉不会对另一个进程造成影响,对于多线程程序,一个线程死掉,整个进程就会死掉。

3、线程的生命周期

原文:Java线程的生命周期

  • 新建(New)状态:当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时的线程情况如下:
  1. 此时JVM为其分配内存,并初始化其成员变量的值;
  2. 此时线程对象没有表现出任何线程的动态特征,线程也不会得到调度。
  • 就绪(Runnable)状态:当线程对象调用了start()方法之后,该线程就处于了就绪状态,此时的线程情况如下:
  1. 此时JVM会为其创建方法调用栈和程序计数器;
  2. 该状态的线程一直处于线程就绪队列(尽管采用的是队列形式,事实上,把它成为可运行池而不是可运行队列。因为CPU的调度不一定是按照先进先出的顺序来调度的),线程并没有开始运行。
  3. 此时的线程等待系统为其分配CPU时间片,并不是说执行了start()方法就立即执行。
  • 运行(Running)状态:当CPU开始调度处于就绪状态的线程时,此时线程获得了CPU的时间片才得以真正开始执行run()方法的线程执行体,则该线程处于运行状态
  • 阻塞(Blocked)状态:处于运行状态的线程在某些情况下,让出CPU的执行权并暂停自己的运行,进入阻塞状态。

当发生如下情况时,线程将会进入阻塞状态:

  1. 线程调用sleep()方法:线程主动放弃CPU的执行权进入阻塞状态(不会释放线程持有的对象锁),等时间到后线程重新进入就绪状态等待CPU的执行权。
  2. 线程调用了一个阻塞式IO方法:在该方法返回之前,该线程一直被阻塞。
  3. 线程试图获取一个同步监视器:但该同步监视器正在被其他线程所持有。
  4. 线程调用了持有锁的wait方法:调用同一个锁对象的notify/notifyAll方法将会让线程重新进入就绪状态等待CPU的执行权。
  • 死亡(Dead)状态:线程进入死亡状态后该线程的线程资源(方法栈、程序计数器等)也随即被系统回收,但是虽然线程资源被回收,线程得对象也许是活的,只不过他已经不是一个单独执行的线程,如果在一个死去的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。

线程进入死亡态的方式:

  1. run()或call()方法执行完成,线程正常结束;
  2. 线程抛出一个未捕获的异常
  3. 直接调用了该线程的stop()方法来结束该线程——该方法容易导致死锁,通常不推荐使用。

4、守护线程

Java提供了两种线程:守护线程与用户线程。守护线程又被称为“服务进程”、“精灵线程”或“后台线程”。

  • 守护线程和用户线程几乎一样,唯一不同之处就在于如果用户线程已经全部退出运行,只剩下守护线程存在,则JVM也就退出了。
  • 在Java语言中,守护线程一般具有较低的优先级,它并非只由JVM内部提供,用户在编写程序时也可以自己设置守护线程,例如在start()方法启动前调用线程对象的setDaemon(true)方法(默认为false:用户线程)。
  • 当在一个守护线程中产生其他线程,那么这些新产生的线程默认还是守护线程。
  • 守护线程的一个典型的例子就是垃圾回收器。只要JVM启动,他始终在运行,实时监控和管理系统中可以被回收的资源。

5、如何创建线程

Java中线程的创建一般由以下三种方法,其中前两种为最常用的方法。

  • 继承Thread类,重写run()方法

Thread本质上也是实现了Runnable接口的一个实例,他代表一个线程的实例,并且,启动线程的唯一方法就是通过Thread类的start()方法。start()方法将启动一个新的线程,并执行run()方法,但是需要注意的是,调用start()方法后并不是立即执行多线程的代码,而是使得该线程变为就绪状态(Runnable),什么时候运行由操作系统说了算。

public class Test extends Thread{//创建线程类

	public void run() {
		//线程的执行方法体
		System.out.println(Thread.currentThread().getName()+"run...");
	}
	public static void main(String[] args){
		new Test().start();//新建开启线程
	}
}

 

  •  实现Runnable接口,并实现该接口的run()方法

主要步骤:

  1. 自定义类并实现Runnable接口,实现run()方法
  2. 创建Thread对象,用实现Runnable接口的对象作为参数实例化该Thread对象。
  3. 调用Thread的start()方法。
public class Test{


	public static void main(String[] args){
		new Thread(new Task()).start();//新建开启线程,将任务交予线程去执行
	}
}
class Task implements Runnable{//创建任务
	public void run() {
		//线程的执行方法体
		System.out.println(Thread.currentThread().getName()+"run...");
	}
}

 

  • 实现Callable接口,重写call方法

Callable接口实际是属于Executor框架中的功能,Callable接口与Runnable接口的功能类似,但提供了比Runnable更强大的功能,主要表现为以下3点:

  1. Callable可以在任务结束后提供一个返回值,Runnable无法提供这个功能。
  2. Callable中的call方法可以抛出异常,而Runnable的run()方法不能抛出异常。
  3. 运行Callable可以将Callable对象传给FutureTask对象然后新建Thread对象传入FutureTask对象,再调用Thread对象的start()方法。FutureTask对象表示异步计算的结果,它提供了检查计算是否完成的方法,调用FutureTask的get()方法以获取结果时,当前线程会阻塞,直到运算结果返回。
    public class Test {
    
    	public static void main(String[] args) throws InterruptedException, ExecutionException {
    		//将任务封装进运算监视器中
    		FutureTask<Integer> task = new FutureTask<>(new Task());
    		new Thread(task).start();// 新建开启线程,将任务交予线程去执行
    		System.out.println(task.get());
    	}
    }
    
    class Task implements Callable<Integer> {// 创建任务
    	@Override
    	public Integer call() throws Exception {
    		Thread.sleep(1000);
    		return 100;
    	}
    }

     

实现多线程的几种方式,建议使用Runnable接口实现,因为很多Java开发人员认为,一个类仅在他们需要被加强或修改时才会被继承。因此,如果没有必要重写Thread类中的其他方法,那么通过继承Thread的实现方式与实现Runnable接口的效果相同,所以优先推荐使用Runnable接口。

6、多线程对性能的影响

与单线程相比,使用多线程总会引入一些额外的性能开销。造成这些开销的操作包括:线程之间的协调(例如加锁、触发信号机内存同步等)、增加的上下文切换、线程的创建和销毁、以及线程的调度等。如果过度地使用线程,那么这些开销甚至会超过由于提高吞吐量、响应性或者计算能力所带来的性能提升。

7、run()方法与start()方法有什么区别

通常,系统通过调用线程类的start()方法来启动一个线程,此时该线程处于就绪状态,而非运行状态,也就意味着这个线程可以被JVM来调度执行。在调度过程中,JVM通过调用线程类的run()方法来完成实际的操作,当run()方法结束后,此线程就会终止。

如果直接调用线程类的run()方法,就会被当作一个普通的函数调用,run()方法的执行是在当前线程中执行的,并不会新建线程来执行,因此直接调用run()方法是无法达到多线程的目的的

8、sleep()方法与wait()方法有什么区别

sleep()是使线程暂停一段时间的方法,当线程在执行过程中时调用线程的静态方法sleep()时会引起线程的调度,使当前线程让出CPU的执行权,一段时间后线程会自动进入就绪状态。wait()也是一种使线程暂停执行的方法,当线程持有x对象的锁时调用x对象的wait方法会引起线程的调度,使当前线程让出CPU的执行权,直到被x对象上的notify()/notifyAll()方法唤醒进入就绪状态,才有可能重新获取CPU的执行权。

具体而言,sleep()方法与wait()方法的区别主要体现如下:

  • 原理不同。sleep()方法是Thread类的静态方法,是线程用来控制自身流程的,它会暂停一段时间后自动就绪。而wait()方法是Object类的方法,用于多线程同步情况下的线程通信,使用这个方法暂停的线程只有用notify()/notifyAll()方法才能使线程就绪。
  • 对锁的处理机制不同。sleep()方法的作用主要是让线程暂停执行一段时间,时间到了自动就绪,不涉及线程间的通信,因此,调用sleep()方法不会释放线程上持有的锁。而调用wait()方法会释放掉它所占用的锁。
  • 使用的条件不同。wait()方法必须当线程持有x对象的锁才能调用x对象的wait()方法。而sleep()方法则可以放在任何地方使用。

9、sleep()方法与yeild()方法的区别

  • sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会,而yeild()方法只会给相同优先级或更高优先级的线程以运行的机会。
  • 调用sleep()方法后会转入阻塞状态,所以,执行sleep()方法的线程在指定时间内肯定不会被调度,而yeild()方法只是使当前线程重新回到可执行状态,所以执行yeild()方法的线程有可能进入就绪状态后马上又被执行。
  • sleep()方法声明抛出InterruptedException,而yeild()不抛出异常。
  • sleep()方法比yeild()方法具有更好的可移植性(操作系统相关)。

10、终止线程的方法有哪些

在Java中,可以使用线程对象的stop()方法与suspend()方法来终止线程,但由于这两种方法都存在安全问题,故而已不建议再使用这两个方法来终止线程,这里说两种比较建议的两种终止线程的方法:

  • 设置flag让线程提前自动结束run()方法的执行。
class Task implements Runnable {// 创建任务
	private volatile Boolean flag;

	public void stop() {
		flag = false;
	}
	@Override
	public void run() {
		while (flag)
			System.out.println("run...");
	}
}

上例中,通过调用Task的stop()方法虽然能够终止线程,但同样存在问题:当线程处于非运行状态时(当sleep()方法被调用或当wait()方法被调用或当I/O阻塞时),上面的方法就不可用了。需要用下边的方法:

  • 调用线程对象的iterrupt()。
  1. 每个线程都有一个boolean类型的中断请求,当调用现成的iterrupt()方法时,这个线程的中断状态将被置位。在Thread类中包含了中断线程以及查询线程中断状态的方法。isInterrupted()方法能返回目标线程的中断状态。静态的interrupted()方法将清除当前线程的中断状态,并返回它之前的值,这个方法也是清除中断状态的唯一方法。
  2. 阻塞方法sleep()、wait()和join()等,都会检查线程何时中断,并且在发现中断时提前返回。它们响应中断时执行的操作包括:清除中断状态抛出InterruptedException,表示阻塞操作由于中断而提前结束。
  3. 当线程在非阻塞状态下中断时,它的中断状态将被设置。
  4. 如果程序因I/O操作而阻塞,基本上要等到I/O完成才能离开这个状态,在这种情况下,无法使用interrupt()方法来使程序离开run()方法。这种情况下可以使用关闭I/O操作的方法,使程序引发IOException异常,run()方法可以通过捕获这个异常来安全地结束线程。

11、线程安全性

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外地同步或协同,这个类都能表现出正确的行为,那么就称为这个类是线程安全的。—————Java并发编程实战

12、Volatile关键字

  • 用volatile声明变量来确保变量的更新对其他线程可见
  • volatile声明的变量不会将该变量上的操作与其他内存操作一起重排序
  • volatile变量不会被缓存在寄存器或其他处理器看不见的地方。
  • volatile变量的读取和写入操作是原子操作

13、ThreadLocal类的设计理念与作用

ThreadLocal类能使线程中的某个值与保存值的对象关联起来。ThreadLocal提供了get与set等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本(当变量是基本数据类型时,存的是基本数据类型的数值,当变量是引用数据类型时,存的是对象的引用)。

从概念上看,可以将TreadLocal<T>视为包含了Map<Thread,T>对象,其中保存了特定于该线程的值,但ThreadLocal的实现并非如此,其将特定于线程的值保存在Thread对象中,当线程终止后,这些值会被作为垃圾进行回收。

public class ConnectionManager {
	private static String DB_URL = "your DB URL";
	private static ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>() {
		public Connection initialValue() {
			try {
				return DriverManager.getConnection(DB_URL);
			} catch (SQLException e) {
				e.printStackTrace();
			}
			return null;
		}
	};

	public static Connection getConnection() {
		return connectionHolder.get();
	}
}

上边程序通过将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接。当调用上边程序的getConnection()方法时,程序会去ThreadLocal中找对应于当前线程的Connection,如果找到了直接返回,如果没有则调用initialValue()方法返回Connection,并将返回的此Connection与当前线程对应起来存进ThreadLocal。

14、同步与异步

  • 在多线程的环境中,经常会碰到数据的共享问题,即当多个线程需要访问同一个资源时,它们需要以某种顺序来确保该资源在某一时刻只能被一个线程使用,否则,程序的运行结果将会时不可预料的,在这种情况下就必须对数据进行同步,例如多个线程同时对同意数据进行写操作,即当线程A需要使用某个资源时,如果这个资源正在被线程B使用,同步机制就会让线程A一直等待下去,直到线程B结束对该资源的使用后,线程A才能使用这个资源,由此可见,同步机制能够保证资源的安全。
  • 异步与非阻塞类是,由于每个线程都包含了运行时自身所需要的数据或方法,因此,在进行输入输出处理时,不必关心其他线程的状态或行为,也不必等到输入输出处理完毕才返回。当应用程序在对象上调用了一个需要花费很长时间来执行的方法,并且不希望让程序等待方法的返回时,,就应该使用异步编程。

15、Synchronized和Lock

  • synchronized:Java提供了一种内置的锁机制来支持线程的同步:同步代码块(Synchronized Block)。同步代码块包含两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字synchronized来修饰的方法就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的synchronized方法以类的Class对象作为锁。
synchronized(lock){
    //访问或修改由锁保护的共享资源
}

每个Java对象都可以用作一个实现同步的锁,这些锁被称为内置锁监视器锁。线程在进入同步代码块之前会自动获得锁,并且在退出(无论是正常退出还是抛出异常退出)同步代码块时自动释放锁。内置锁是一种互斥锁、可重入锁

  • Lock:Lock是JDK 1.5中增加的一种新的加锁机制。与内置锁的加锁机制不同的是,Lock提供了一种无条件的可轮询的定时的以及可中断的锁的获取操作,所有加锁和解锁的方法都是显式的。

 Lock接口:

public interface Lock{

    void lock();//获取锁,在获取锁之前一直阻塞
    //获取锁,在获取锁之前一直阻塞,但是其允许在等待一个锁的同时仍能响应中断
    void lockInterruptibly() throws InterruptedException;
    //如果锁可用,则获取锁,并立即返回值 true。如果锁不可用,则此方法将立即返回值 false。
    boolean tryLock();
    //如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁,返回true
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();//释放锁
    Condition newCondition();
}

ReentrantLock实现了Lock接口,并提供了与Synchronized相同的互斥、可重入功能。

ReentrantLock锁的使用:

Lock lock=new ReentrantLock();
lock.lock();
try{

    //代码    

}finally{
    lock.unlock();
}

 

  • Lock锁与内置锁的区别:
  1. 内置锁在执行完同步代码块后由JVM自动释放,而Lock锁需要在finally代码块中显式(lock.unlock())的释放锁。
  2. Lock锁提供了定时锁、轮询锁的获取锁的方式,可以有效的避免死锁发生。
  3. 内置锁的实现是非公平锁。而Lock的实现ReentrantLock可以通过构造函数传入true实现公平锁,即按照锁的申请顺序获取锁。
  4. Lock锁提供了可中断的锁获取操作,即允许在等待一个锁的同时仍能响应中断。
  5. Lock锁提供了一个newCondition()函数,用于获取Lock锁上的一个条件队列,其可用于线程间的通信。对于内置锁只能有一个相关联的条件队列,而Lock锁可以通过newCondition()函数创建多个条件队列

使用两个条件队列的简单资源类如下,其可用于生产者、消费者模式:

public class Test {
	private final Lock lock = new ReentrantLock();

	private Condition put = lock.newCondition();
	private Condition take = lock.newCondition();

	private int count = 0;

	public void put() throws InterruptedException {
		lock.lock();
		try {
			while (count > 0) {
				put.await();
			}
			count++;
			System.out.println(count);
			take.signalAll();
		} finally {
			lock.unlock();
		}
	}

	public void take() throws InterruptedException {
		lock.lock();
		try {
			while (count == 0) {
				take.await();
			}
			count--;
			System.out.println(count);
			put.signalAll();
		} finally {
			lock.unlock();
		}
	}
}

注:在一些内置锁无法满足需求的情况下,ReentrantLock可以作为一种高级工具。当需要一些高级功能时才应该使用ReentrantLock,这些功能包括:可定时的、可轮询的与可中断的锁获取操作,公平队列等。否则,还是应该优先使用synchronized。

16、读-写锁ReadWriteLock

读写锁:一个资源可以被多个读操作同时访问,或者被一个写操作访问,但两者不能同时进行。

  • ReadWriteLock的实现ReentrantReadWriteLock
  1. ReentrantReadWriteLock为读写两种锁都提供了可重入的加锁语义。
  2. 在构造时也可以选择是一个非公平的锁(默认)还是一个公平的锁。
  3. 写线程在持有写入锁的情况下可以访问读取锁的方法,即写入锁可以“降级”为读取锁。反之,从读线程不能升级为写线程。
  4. 写入锁只能有唯一的所有者,并且只能由获得该锁的线程来释放。

17、加锁会带来那些性能问题?如何解决?

  • 当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。当程序中锁的竞争过多时会导致阻塞线程过多,如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞I/O,等待获取发生竞争的锁、或者在条件变量上等待),与CPU密集型程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量
  • 减少锁的竞争:
  1. 减少锁的持有时间:缩小同步代码快的范围。
  2. 降低锁的请求频率:减小所得粒度(使用锁分解和锁分段技术)。
  3. 使用替代独占锁:使用读写锁、并发容器和原子变量。

18、并发容器

JDK 1.5提供了多种并发容器类来改进同步容器的性能。同步容器将所有对容器的操作都串行化,以实现线程安全。这种方式严重降低了程序的并发性,当多个线程竞争容器的锁时,吞吐量会严重降低。

并发容器针对上述不足,提供了多线程并发访问,增加了ConcurrentHashMap,用来代替同步器基于散列的Map,以及CopyOnwriteArrayList,用于代替同步的List。

  • ConcurrentHashMap
  1. ConcurrentHashMap使用一种粒度更细的加锁机制来实现更大程度的共享,这种机制称为分段锁。在ConcurrentHashMap的实现中使用了一个包含16个锁的数组,每个锁保护所有散列桶的1/16,其中第N个散列桶由第(N mod 16)个锁保护。假设散列函数具有合理的分布性,并且关键字能够实现均匀分布,那么这大约能把对于锁的请求减少到原来的1/16。
  2. ConcurrentHashMap有些方法需要跨段,比如size()containsValue(),它们可能需要锁定整个表而而不仅仅是某个段,这需要按顺序锁定所有段,操作完毕后,又按顺序释放所有段的锁。这里“按顺序”是很重要的,否则极有可能出现死锁。
  3. ConcurrentHashMap提供的迭代器不会抛出ConcurrentModificationException,因此不需要在迭代过程中对容器加锁。ConcurrentHashMap返回的迭代器具有弱一致性,而并非“及时失败”。弱一致性的迭代器可以容忍并发的修改,当创建迭代器时会遍历已有的元素。
  • CopyOnWriteArrayList
  1. CopyOnWriteArrayList使用了一种叫写时复制的方法,当有新元素添加到CopyOnWriteArrayList时,先从原有的数组中拷贝一份出来,然后在新的数组做写操作,写完之后,再将原来的数组引用指向到新数组。
  2. CopyOnWriteArrayList的整个add操作都是在的保护下进行的。这样做是为了避免在多线程并发add的时候,复制多个副本出来。
  3. CopyOnWriteArrayList的读操作没有加锁,所以支持多线程读操作。

19、阻塞队列

BlockingQueue扩展了Queue,增加了可阻塞的插入(put)和获取(take)等操作。如果队列为空,那么获取元素的操作将一直阻塞,直到队列中出现一个可用的元素。如果队列已满(对于有界队列),那么插入元素的操作将一直阻塞直到队列中出现可用的空间。在“生产者-消费者”这种设计模式中,阻塞队列是非常有用的。

在类库中包含了BlockingQueue的多种实现,其中,

  1. LinkedBlockingQueueArrayBlockingQueue是FIFO队列,二者分别与LinkedList和ArrayList类似,其中LinkedBlockingQueue是一个无界队列,而ArrayBlockingQueue是一个有界队列。
  2. PriorityBlockingQueue是一个按优先级排序的队列,与其他有序容器一样,PriorityBlockingQueue既可以根据元素的自然顺序排序(实现了Compareble方法),也可以使用Comparator来比较。
  3. SynchronousQueue不是一个真正的队列,而是一种在线程之间进行移交的机制。要将一个元素放入SynchronousQueue,必须有另一个线程正在等待接受这个元素。

20、生产者-消费者模式

考虑这样一个饭店,它有两个动作,一个是做菜,一个是上菜。它们的工作必须是做完菜然后等待上菜,上完菜才可以继续做菜,如此循环。这是一个任务协作的示例:做菜就相当于生产者生产,上菜就相当于消费者消费。其不同的实现方式如下:

  • wait()/notify()形式
public class Restaurant {
	private Meal meal;
	private int count = 0;

	public synchronized void serving() {
		while (meal == null) {
			try {
				wait();
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
		System.out.println("serving:" + meal);
		meal = null;
		notifyAll();
	}

	public synchronized void cooking() {
		while (meal != null) {
			try {
				wait();
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
		}
		meal = new Meal(++count);
		System.out.println("cooking:" + meal);
		notifyAll();
	}

	public static void main(String[] args) {
		Restaurant restaurant = new Restaurant();
		for (int i = 0; i < 5; i++)
			new Thread(new Customer(restaurant)).start();
		for (int i = 0; i < 5; i++)
			new Thread(new Chef(restaurant)).start();
	}
}

class Meal {
	private final int orderNum;

	public Meal(int orederNum) {
		this.orderNum = orederNum;
	}

	public String toString() {
		return "Meal " + orderNum;
	}
}

class Customer implements Runnable {

	private Restaurant restaurant;

	public Customer(Restaurant restaurant) {
		this.restaurant = restaurant;
	}

	@Override
	public void run() {
		while (true)
			restaurant.serving();
	}
}

class Chef implements Runnable {
	private Restaurant restaurant;

	public Chef(Restaurant restaurant) {
		this.restaurant = restaurant;
	}

	@Override
	public void run() {
		while (true)
			restaurant.cooking();
	}
}
  • Lock和Condition形式
/**
 * 餐馆类
 * @author Herry
 *
 */
public class Restaurant {

	private Meal meal;
	private int count = 0;
	private Lock lock = new ReentrantLock();
	private Condition serving = lock.newCondition();
	private Condition cooking = lock.newCondition();

	public void serving() {
		lock.lock();
		try {
			while (meal == null) {
				try {
					serving.await();
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
			System.out.println("serving:" + meal);
			meal = null;
			cooking.signalAll();
		} finally {
			lock.unlock();
		}
	}

	public void cooking() {
		lock.lock();
		try {
			while (meal != null) {
				try {
					cooking.await();
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
			meal = new Meal(++count);
			System.out.println("cooking:" + meal);
			serving.signalAll();
		} finally {
			lock.unlock();
		}
	}

	public static void main(String[] args) {
		Restaurant restaurant = new Restaurant();
		for (int i = 0; i < 5; i++)
			new Thread(new Customer(restaurant)).start();
		for (int i = 0; i < 5; i++)
			new Thread(new Chef(restaurant)).start();
	}
}

class Meal {
	private final int orderNum;

	public Meal(int orederNum) {
		this.orderNum = orederNum;
	}

	public String toString() {
		return "Meal " + orderNum;
	}
}
/**
 * 消费者:专门去特定餐馆吃饭
 * @author Herry
 *
 */
class Customer implements Runnable {

	private Restaurant restaurant;

	public Customer(Restaurant restaurant) {
		this.restaurant = restaurant;
	}

	@Override
	public void run() {
		while (true)
			restaurant.serving();
	}
}
/**
 * 服务员,专门给特定餐馆打工
 * @author Herry
 *
 */
class Chef implements Runnable {
	private Restaurant restaurant;

	public Chef(Restaurant restaurant) {
		this.restaurant = restaurant;
	}

	@Override
	public void run() {
		while (true)
			restaurant.cooking();
	}
}
  • BlockingQueue实现
public class Restaurant {

	private BlockingQueue<Meal> queue = new ArrayBlockingQueue<>(10);
	private AtomicInteger count = new AtomicInteger(0);

	public void serving() {
		try {
			System.out.println("serving" + queue.take());
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}

	public void cooking() {
		try {
			Meal meal = new Meal(count.addAndGet(1));
			System.out.println("cooking" + meal);
			queue.put(meal);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}

	public static void main(String[] args) {
		Restaurant restaurant = new Restaurant();
		for (int i = 0; i < 5; i++)
			new Thread(new Customer(restaurant)).start();
		for (int i = 0; i < 5; i++)
			new Thread(new Chef(restaurant)).start();
	}
}

class Meal {
	private final int orderNum;

	public Meal(int orederNum) {
		this.orderNum = orederNum;
	}

	public String toString() {
		return "Meal " + orderNum;
	}
}

/**
 * 消费者:专门去特定餐馆吃饭
 * @author Herry
 *
 */
class Customer implements Runnable {

	private Restaurant restaurant;

	public Customer(Restaurant restaurant) {
		this.restaurant = restaurant;
	}

	@Override
	public void run() {
		while (true)
			restaurant.serving();
	}
}

/**
 * 服务员,专门给特定餐馆打工
 * @author Herry
 *
 */
class Chef implements Runnable {
	private Restaurant restaurant;

	public Chef(Restaurant restaurant) {
		this.restaurant = restaurant;
	}

	@Override
	public void run() {
		while (true)
			restaurant.cooking();
	}
}

21、同步工具类(闭锁、信号量、栅栏)

  • 闭锁

闭锁可以用来确保某些活动直到其他活动都完成后才继续执行。CountDownLatch是一种灵活的闭锁实现,它可以使一个或多个线程等待一组事件发生。闭锁状态包括一个计数器,该技术器被初始化为一个正数,表示需要等待的事件数量。countDown方法递减计数器,表示有一个事件已经发生了,而await方法等待技术器到达零,者表示所有需要等待的事件都已经发生。如果计数器的值非零,那么await会一直阻塞到计数器为零,或者等待中的线程中断,或者等待超时。

  • 信号量

Semaphore中管理着一组虚拟的许可(permit),许可的初始数量可通过构造函数来指定。在执行操作时可以首先获得许可(只要还有剩余的许可),并在使用以后可以释放许可。如果没有许可,那么acquire方法将阻塞直到有许可(或者直到被中断或者操作超时)。release方法返回一个许可给信号量。计算信号量的一种简化形式是二值信号量,即初始值为1的Semaphore。二值信号量可以用互做斥体(mutex),并具备不可重入的加锁语义:谁拥有这个唯一的许可,谁就拥有了互斥锁。

Semaphore可以用于实现资源池,例如数据库连接池。我们可以构造一个固定长度的资源池,将Semaphore的计数值初始化为池的大小,并在从池中获取一个资源之前首先调用acquire方法获取一个许可,在将资源返回给池之后调用release释放许可。这样,当池之连接数不够时,获取连接的线程就会阻塞的去等待,直到有可用链接。

  • 栅栏

栅栏(Barrier)类似于闭锁,他能阻塞一组线程直到某个事件发生,与闭锁不同的是,它需要所有线程必须同时到达栅栏的位置才能继续执行。栅栏可以用于协调多个线程,它的作用举个例子可以是:假如几个家庭决定在某个地方集合:“所有人6:00在麦当劳碰头,到了以后要等其他人,之后再讨论下一步要做的事情。”

CyclicBarrier可以使一定数量的线程反复地在栅栏位置汇集,当线程到达栅栏位置时将调用await方法,这个方法将阻塞直到所有线程都到达栅栏位置。如果所有线程都到达了栅栏位置,那么栅栏将打开,此时所有线程都被释放,而栅栏将被重置以便下次使用。如果对await的调用超时,或者await阻塞的线程被中断,那么栅栏就被认为是打破了,所有阻塞的await调用都将终止并抛出BrokenBarrierException。CyclicBarrier还可以使你将一个栅栏操作传递给构造函数,这是一个Runnable,当成功通过栅栏时会(在一个子任务线程中)执行它,但在阻塞线程被释放之前时不能执行的。

public class BarrierTest {
	private Task[] tasks;
	private CyclicBarrier barrier;

	public BarrierTest(int taskNum) {
		tasks = new Task[taskNum];
		for (int i = 0; i < tasks.length; i++) {
			tasks[i] = new Task(i + 1);
		}
		barrier = new CyclicBarrier(taskNum, new Runnable() {
			private int i = 1;

			@Override
			public void run() {
				System.out.println("round " + i++ + " ok!");
			}
		});
	}

	public static void main(String[] args) {
		new BarrierTest(5).start();
	}

	private class Task implements Runnable {
		public int num = 0;

		public Task(int num) {
			this.num = num;
		}

		@Override
		public void run() {
			while (true) {
				try {
					Thread.sleep(num * 100);
					System.out.println(num + " ready");
					barrier.await();
				} catch (InterruptedException e) {
					e.printStackTrace();
				} catch (BrokenBarrierException e) {
					e.printStackTrace();
				}
			}
		}
	}

	public void start() {
		for (int i = 0; i < tasks.length; i++) {
			new Thread(tasks[i]).start();
		}
	}
}

 

22、乐观锁与悲观锁

  • 悲观锁

悲观锁总是假设最坏的情况,每次取数据时都认为其他线程会修改数据,所以都会加锁,从而实现当前线程访问数据时其他线程需要阻塞挂起等待。java中,synchronized的思想就是悲观锁。

  • 乐观锁

乐观锁总是认为不会产生并发问题,每次对数据的操作总认为不会与其他线程发生冲突,因此不会上锁,但是在更新时会判断其他线程在其开始访问数据后有没有对数据进行修改,一般会使用版本号机制或者CAS操作实现。

  1. version方式:一般是在数据中维护一个数据版本号字段,当数据被修改时,版本字段+1。当某个线程要更新数据值时,会读取并保存数据和当前的版本号,在提交更新时会判断保存的版本号是否与当前的版本号一致,若一致则更新,否则重重新执行更新操作。
  2. CAS操作方式:Compare and Swap(比较并交换),其在特定处理器上是一个原子操作。

23、CAS

  • CAS的原理

CAS是乐观加锁的一种方式,其是利用处理器的原子操作指令Compare and Swap(比较并交换)

CAS包含了3个操作数——需要读写的内存位置V进行比较的值A拟写入的新值B。当且仅当V的值等于A时,CAS才会通过原子方式用新值B来更新V的值,否则不会执行任何操作。CAS的含义是:“我认为V的值应该是A,如果是,那么将V的值更新为B,否则不修改并告诉我V的值实际是多少”。

当多个线程尝试使用CAS同时更新同一个变量是,只有其中一个线程能更新变量的值,而其他线程都将失败。然而,失败的线程并不会被挂起(这与获取锁的情况不同:当获取锁失败时,线程将被挂起),而是被告知在这次竞争中失败了,可以再次尝试。

  • Java对CAS的支持

JDK 1.5中引入了原子变量类(例如java.util.concurrent.atomic中的AtomicXxx),在这些类中使用了为数字类型和引用类型提供的一种高效的CAS操作,而在java.util.concurrent中的大多数类在实现时则直接或间接地使用了这些原子变量类。

  • CAS导致地ABA问题

ABA问题是一中异常现象。在CAS操作中将判断“V的值是否仍然是A?”,并且如果是的话就继续执行更新操作。在大多数情况下,这种判断是足够的,然而,有时候还需要知道“自从上次看到V的值为A以来,这个值是否发生了变化?”,在某种情况下,如果V的值先由A变为B,再由B变成A,该如何判断?

一种解决方案为:为数据维护一个版本号,每次更新都判断版本号是否改变。

24、ThreadPool

  • 线程池的优点

问题:前边提到了多线程会引入线程协调开销线程创建与销毁开销线程切换开销线程调度开销等。在一定的范围内,增加线程可以提高系统的吞吐率,但超出一定范围,再创建更多线程只会降低程序的执行速度。

优势:1.线程创建与销毁开销。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。2.提高响应速度。当任务到达时,任务可以不需要等待线程创建就能立即执行。3.提高线程的可管理性。

  • Executor框架

Executor接口:

public interface Executor{
    void execute(Runnable command);
}

Java.util.concurrent提供了一种灵活的线程池实现作为Executor框架的一部分。Executor接口为强大的异步任务执行框架提供了基础,在Executor框架中Runnable表示待执行的任务,要执行任务只需要将任务提交给线程池即可。Executor基于生产者-消费者模式,提交任务的操作相当于生产者,执行任务的线程则相当于消费者。

  • 线程池的创建

通过调用Executors类中的静态工厂方法之一来创建一个线程池:

  1. newFixedThreadPool。它将创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,直到达到线程池的最大数量,这时线程池的规模将不在变化(当某个线程发生异常(Exception)而结束时,那么线程池会补充一个新的线程)。
  2. newCachedThreadPool。它将创建一个可缓存的线程池,如果线程池的当前规模超过了要处理的任务数时,那么将回收空闲的线程,而当任务数增加时,则添加新的线程。其线程池的规模不存在任何限制。
  3. newSingleThreadPool。它是一个单线程的线程池。线程池中只有一个线程来处理任务,如果这个线程发生异常而结束,则会创建一个新线程来代替。来到此线程池的任务将依照其在队列(FIFO、LIFO、优先级)中的顺序来串行执行。
  4. newScheduledThreadPool。创建一个固定长度的线程池,而且以延迟或定时的方式来执行任务。

通过线程池的具体实现类ThreadPoolExecutor来定制线程池:

ThreadPoolExecutor类的构造函数如下:

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              RejectedExecutionHandler handler) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             Executors.defaultThreadFactory(), handler);
    }
  1. corePoolSize:线程池的基本大小。其代表了在没有任务执行时线程池的大小,并且只有在工作队列满了的情况下才会创建超出这个数量的线程。
  2. maximumPoolSize:线程池的最大大小。表示可同时活动的线程数量的上限。
  3. keepAliveTime:线程的存活时间。如果某个线程的空闲时间超过了存活时间,那么将被标记为可回收,并且当线程池的当前大小超过了基本大小时,这个线程将被终止。
  4. unit:表示keepAliveTime的单位;可以为天、小时、分钟、毫秒、微妙、纳秒。
  5. workQueue:ThreadPoolExecutor允许提供一个BlockingQueue来保存等待的任务,基本的任务排队方法有三种:无界队列(LinkedBlockingQueue)、有界队列(ArrayBlockingQueue)和同步移交(SynchronousQueue)。
  6. handler:当有界队列被填满后,饱和策略开始发挥作用。此变量设置线程池的饱和策略。ThreadPoolExecutor的饱和策略也可以通过调用setRejectedExecutionHandler来修改。(如果某个任务被提交到一个已关闭的Executor时,也会用到饱和策略)。饱和策略后边详细介绍。
  • Executor的生命周期

Executor扩展了ExecutorService接口,添加了一些用于生命周期管理的方法(同时还有一些用于任务提交的遍历方法)。ExecutorService中的生命周期管理方法:

public interface ExecutorService extends Executor {
	void shutdown();
	List<Runnable> shutdownNow();
	boolean isShutdown();
	boolean isTerminated();
	boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException;
	// ……其他用于任务提交的便利方法
}

ExecutorService的生命周期有3种状态:运行、关闭和已终止。

  1. 运行:ExecutorService在初始创建时处于运行状态。
  2. 关闭:关闭状态下的线程池将不在接收任何任务。调用shutdown方法将执行平缓的关闭过程:不在接收新任务,同时等待已经提交的任务执行完成(包括那些还未开始执行的任务)。调用shutdownNow将执行粗暴的关闭方式:它将尝试取消所有运行中的任务,并且不再启动队列种尚未开始执行的任务。
  3. 终止:等所有任务都执行完后,线程池进入终止状态。调用awaitTermination方法来等待线程池到达终止状态,或者调用isTerminated来的轮询线程池是否已经终止。
  •  线程池的饱和策略

前边在线程池的创建中提到,ThreadPoolExecutor的创建可以为其指定饱和策略来应对当线程池的工作队列被填满后,新任务应该怎么处理的问题。JDK提供了几种不同的RejectedExecutionHandler实现,每种实现都包含有不同的饱和策略:AbortPolicyCallerRunsPolicyDiscardPolicyDiscardOldestPolicy

  1. “中止(Abort)”策略是默认的饱和策略,该策略将抛出未检查的RejectedExecutionException。调用者可以捕获这个异常,然后根据需求编写自己的处理代码。
  2. “抛弃(Discard)”策略会悄悄抛弃该任务。
  3. “抛弃最旧的(Discard-Oldest)”策略则会抛弃下一个将被执行的任务,然后尝试重新提交新的任务。(如果工作队列是一个优先队列,那么它将会导致优先级最高的任务被抛弃)
  4. “调用者运行(Caller-Runs)”策略不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,即它不会在线程池中的某个线程中执行新提交的任务,而是在调用execute的线程中执行该任务。(当线程池和Caller-Runs策略运行在服务端时,当工作队列被填满后,下一个任务会在调用execute时的主线程中执行。由于执行任务需要一定的时间,因此主线程至少在一段时间内不能提交任何任务,从而使线程池有时间来处理完正在执行的任务。在此期间,主线程不会调用accept,因此到达的请求将被保存在TCP层的缓存中,如果持续过载导致TCP层的缓存被填满,那么TCP由于流量控制机制会导致客户端发送请求阻塞。因此,当服务器过载时,这种过载情况会逐渐向外蔓延开来,从而达到一种平缓的处理状态。)

25、造成死锁的原因及其预防

  • 死锁的原因
  1. 锁顺序死锁:多个线程相互持有彼此正在等待的锁而又不释放自己已持有的锁时会发生死锁。
  2. 资源死锁:①连接资源死锁(假如有两个不同数据库的连接池,一个任务需要连接两个数据库,A线程持有了1号数据库的连接,并等待与2号数据库连接,而线程B则持有2号数据库的连接并等待与1号的连接)。②线程饥饿死锁(只有一个线程的线程池种,一个已提交任务在等待另一个任务的完成)。
  • 死锁的避免
  1. 严格分析锁的顺序。
  2. 使用支持定时的锁。像Lock锁。
  3. 通过线程转储信息来分析死锁。

26、AQS

参考:AQS结构及原理分析

27、自旋锁、偏向锁、轻量级锁

Java中的线程模型使用的是一对一的线程模型,一条Java线程就映射到一条轻量级进程中。每个轻量级进程都有一个内核线程支持,所以每个轻量级进程都成为一个独立的调度单元。同时,由于它是基于内核线程实现的,所以个种线程的操作,如创建、销毁、同步,都要进行系统调用,而系统调用的代价相对较高,需要在用户态和内核态中来回切换。JDK1.6中,为了解决线程运行中频繁的内核调用引起的性能下降而引入了自旋锁、偏向锁、轻量级锁等同步优化技术。

  • 自旋锁

由于许多程序中,每个线程对共享数据的同步访问只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得(Java线程的挂起恢复会涉及到内核调用)。所以,如果物理机器有一个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的线程“等待”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

下面梳理一下偏向锁和轻量级锁的执行逻辑,在读之前建议先看一下有关对象头中有关Mark Word的基础。

  • 偏向锁逻辑

来源:偏向锁到底是怎么个回事啊啊啊啊作者:IT一刻钟公众号

  1. 线程A第一次访问同步块时,先检测对象头Mark Word中的标志位是否为01,依次判断此时对象锁是否处于无锁状态或者偏向锁状态(匿名偏向锁)
  2. 然后判断偏向锁标志位是否为1,如果不是,则进入轻量级锁逻辑(使用CAS竞争锁),如果是,则进入下一步流程;
  3. 判断是偏向锁时,检查对象头Mark Word中记录的Thread Id是否是当前线程ID,如果是,则表明当前线程已经获得对象的锁,以后该线程进入同步块时,不需要CAS进行加锁,只会往当前线程中添加一条Displaced Mark Word为空的Lock Record,用来统计重入的次数。退出同步块释放偏向锁时,则依次删除除对应的Lock Record,但是不会修改对象头中的Thread Id;
  4. 如果对象头Mark Word中Thread Id不是当前线程ID,则进行CAS操作,企图将当前线程ID替换进Mark Word。如果当前对象锁状态处于匿名偏向锁状态(可偏向未锁定),则会替换成功(将Mark Word中的Thread Id由匿名0改成当前线程ID,在当前线程栈中找到内存地址最高的可用Lock Record,将线程ID存入),获取到锁,执行同步代码块。
  5. 如果对象锁已经被其他线程占用,则会替换失败,开始进行偏向锁撤销,这也是偏向锁的特点,一旦出现线程竞争,就会撤销偏向锁。
  6. 偏向锁的撤销需要等待全局安全点(safe point,代表了一个状态,在该状态下所有线程都是暂停的),暂停持有偏向锁的线程,检查持有偏向锁的线程状态(遍历当前JVM的所有线程,如果能找到,则说明偏向的线程还存活),如果线程还存活,则检查线程是否在执行同步代码块中,如果是,则升级为轻量级锁,进行CAS竞争锁;
  7. 如果持有偏向锁的线程未存活,或者持有偏向锁的线程未在执行同步代码块中的代码,则进行校验是否允许重偏向,如果不允许冲偏向,则撤销偏向锁,将Mark Word设置为无锁状态(未锁定不可偏向状态),然后升级为轻量级锁,进行CAS竞争锁。
  8. 如果允许冲偏向,设置为匿名偏向锁状态,CAS将偏向锁重新指向线程A(在对象头和线程栈帧的锁记录中存储当前线程ID);
  9. 唤醒暂停的线程,从安全点继续执行代码。
  • 轻量级锁逻辑

参考:死磕Synchronized底层实现--轻量级锁

轻量级锁的获取:

  1. 当线程A进入同步代码块时,如果锁对象处于无锁状态(未锁定不可偏向状态)偏向锁状态(已偏向其他线程)或者轻量级锁状态时,则这时候会构建一个无锁状态的Mark Word(锁状态标志位01)设置到线程A栈帧的Lock Record中去。(我们称Lock Record中存储对象Mark Word的字段叫Displaced Mark Word)。
  2. 线程A用CAS操作尝试将对象的Mark Word更新位指向Lock Record的指针(其CAS更新操作的判断依据时第1步中构建的无锁状态的Mark Word与对象头中的Mark Word是否相同)。如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后2bit)将转变为“00”,即表示此对象处于轻量级锁定状态。如果当前锁的状态不是无锁(锁状态标志位非01),则CAS操作会失败,接着虚拟机会判断当前锁对象的Mark Word是否指向当前线程的栈帧,如果是则说明当前线程已经拥有了这个对象的锁,那么直接将Lock Record的Displaced Mark Word设置为null。
  3. 如果第2步中CAS操作失败且锁对象的Mark Word并不指向当前线程,则当前线程自旋一定次数获取锁,若成功获取锁则执行代码块,否则,锁升级为重量级锁。

轻量级锁的释放:

  1. 当线程A成功获取轻量级锁并执行完代码块时会将锁释放。前边提到每次获取锁时都在线程的栈帧中记录Lock Record,因此锁释放时也会根据Lock Record来释放。
  2. 当判断Lock Record中Displaced Mark Word字段为null时,说明是重入,故直接释放Lock Record,否则,用CAS替换Displaced Mark Word到锁对象的Mark Word中去。
  3. 当第2步中替换成功时,锁成功释放,接下来,其他线程可以重新获取锁。但当替换失败时,则说明锁已经升级为重量级锁,需要执行重量级锁的释放流程。
  • 总结
  1. 偏向锁:可以提高有同步但无竞争的程序性能,即它适用于只有一个线程执行同步代码块的情形。当程序中一个同步代码块被多个线程争相执行时,那偏向锁由于其受到竞争时的升级操作会降低程序的性能。
  2. 轻量级锁:可以提高交替竞争锁的程序的性能,即它使用与有多个线程交替获取锁的程序,它利用CAS短时间的自旋获取锁避免了使用互斥量的开销。当程序中锁的竞争程度过大时,自旋时间变长会使程序做过多无用功,从而降低了程序性能。

 

 

 


参考:Java并发编程实战

 

  • 1
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值