Java多线程详解

Java多线程详解

参考文章:https://www.cnblogs.com/snow-flower/p/6114765.html

一、线程与进程

进程

进程是操作系统中运行的一个任务(一个应用程序运行在一个进程中)

线程

线程在java平台上是指从一个线程对象的start()开始,运行run方法体中的那一段相对独立的过程。

一个线程是进程的一个顺序执行流

线程与进程的区别

​ (1)进程之间不能共享数据,线程可以,进程有独立的存储空间,
​ 而线程依附于进程,共用自己所属进程的内存

​ (2)系统创建进程需要为该进程重新分配系统资源,故创建线程代价比较小;

​ (3)进程是主体,线程依附于进程存在,进程是一个程序,
​ 线程是程序的分支结构,为了完成某些特定需求而添加的。

并发原理

多个线程“同时”运行只是我们感官上的一种表现

事实上线程是并发运行的,OS将时间划分为很多时间片段(时间片)
尽可能均匀分配给每一个线程,获取时间片段的线程被CPU运行,而其他线程全部等待

所以微观上是走走停停的,宏观上都在运行
这种现象叫并发,但是不是绝对意义上的“同时发生"

注意:并发问题与高并发问题不是同一个问题,高并发考虑的是机器的负载能力

二、线程的创建与启动

创建线程:

方式一:继承Thread线程类

定义一个线程类继承Thread类,重写其中的run方法,run方法中定义该线程需要完成的任务

package thread;

public class ThreadDemo {
	public static void main(String[] args) {
		//创建线程1
		Thread t1 = new MyThread();
		
		//使用匿名内部类创建Thread线程
		Thread t2 = new Thread(){
			@Override
			public void run() {
				System.out.println("我是匿名内部类线程2");
			}
		};
		
		//启动线程
		t1.start();
		t2.start();
	}
}
//定义线程
class MyThread extends Thread{
	@Override
	public void run() {
		System.out.println("我是线程1");
	}
}
方式二:实现Runnable接口
产生方式二的原因:

第一种创建线程的方式优点是创建简单方便,但是缺点也比较明显

  1. 由于java是单继承的,这导致继承了线程就无法再继承其他的类,这会导致无法重用其他超类的方法,而产生继承冲突问题
  2. 定义线程的同时重写run方法,这就等于规定了线程要执行的具体任务,导致线程与其执行的任务产生必然的耦合关系,不利于线程的重用

所以针对这种情况我们可以用实现Runnable接口的方式来创建线程

package thread;

public class RunnableDemo {
	public static void main(String[] args) {
		//创建线程1
		Runnable r1 = new MyRunnable();
		Thread t1 = new Thread(r1);
		
		//使用匿名内部类创建上述
		Runnable r2 = new Runnable(){
			@Override
			public void run() {
				System.out.println("我是匿名内部类创建的R2");
			}
		};
		//注意Runable都需要向上造型为Thread
		Thread t2 = new Thread(r2);
		
		//启动线程
		t1.start();
		t2.start();
	}
}

class MyRunnable implements Runnable{
	@Override
	public void run() {
		System.out.println("我是Runable1");
	}
}
方式三:通过Callable和Future创建线程(补充,了解一下,知道就行了)
产生方式三的原因:

上述2种方式都有一个缺陷就是:在执行完任务之后无法获取执行结果

如果需要获取执行结果,就必须通过共享变量或者使用线程通信的方式来达到效果,这样使用起来就比较麻烦

而自从Java 1.5开始,就提供了Callable和Future,通过它们可以在任务执行完毕之后得到任务执行结果。

Callable

Callable位于java.util.concurrent包下,它也是一个接口,同Rannable类似
在它里面也只声明了一个方法,只不过这个方法叫做call():

public interface Callable<V> {
    /**
     * Computes a result, or throws an exception if unable to do so.
     *
     * @return computed result
     * @throws Exception if unable to compute a result
     */
    V call() throws Exception;
}

Callable为一个泛型接口,call()函数的返回类型就是传递进来的V类型

Future

Future就是对于具体的Runnable或者Callable任务的执行结果进行取消、查询是否完成、获取结果。必要时可以通过get方法获取执行结果,该方法会阻塞直到任务返回结果。

Future类位于java.util.concurrent包下,它是一个接口
其主要功能为:判断任务是否完成;能够中断任务;能够获取任务执行结果

FutureTask

因为Future只是一个接口,所以是无法直接用来创建对象使用的,因此就有了FutureTask
FutureTask是Future接口的一个唯一实现类

FutureTask类实现了RunnableFuture接口,RunnableFuture继承了Runnable接口和Future接口,而FutureTask实现了RunnableFuture接口。所以它既可以作为Runnable被线程执行,又可以作为Future得到Callable的返回值。

使用示例
public class Test {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newCachedThreadPool();
        Task task = new Task();
        Future<Integer> result = executor.submit(task);
        executor.shutdown();
         
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
         
        System.out.println("主线程在执行任务");
         
        try {
            System.out.println("task运行结果"+result.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
         
        System.out.println("所有任务执行完毕");
    }
}
class Task implements Callable<Integer>{
    @Override
    public Integer call() throws Exception {
        System.out.println("子线程在进行计算");
        Thread.sleep(3000);
        int sum = 0;
        for(int i=0;i<100;i++)
            sum += i;
        return sum;
    }
}

启动线程:

启动线程是调用线程的start方法,而不是直接调用run方法
当线程的start方法调用后,线程纳入到线程调度中,
当其第一次分配到时间片开始运行时,它的run方法会自动被执行

run方法中是线程的任务,start方法是将线程纳入到线程调度中

步骤:

  1. 向上造型创建线程对象:Thread t = new 定义的线程类()
  2. 调用线程的start的方法:t.start()

获取线程信息的相关方法

获取主线程 currentThread()

获取线程名字 getName()

获取唯一标识ID(非空且唯一) getId()

获取线程的优先级 getPriority()

看线程是不是活着的 isAlive()

看线程是否被守护 isDaemon()

看线程是不是被中断了 isInterrupted()

三、线程的状态(线程的生命周期)

线程状态转换
ps.

resume()、suspend()、stop(),这三个方法分别是重启线程、挂起线程、终止线程,这三个方法已经弃用

1、新建状态

用new关键字和Thread类或其子类建立一个线程对象后,该线程对象就处于新生状态。
处于新生状态的线程有自己的内存空间,通过调用start方法进入就绪状态(runnable)。

注意:能对已经启动的线程再次调用start()方法,否则会出现Java.lang.IllegalThreadStateException异常。

2、就绪状态

处于就绪状态的线程已经具备了运行条件,但还没有分配到CPU,处于线程就绪队列(尽管是采用队列形式,事实上,把它称为可运行池而不是可运行队列。因为cpu的调度不一定是按照先进先出的顺序来调度的),等待系统为其分配CPU。
等待状态并不是执行状态,当系统选定一个等待执行的Thread对象后,它就会从等待执行状态进入执行状态,系统挑选的动作称之为“cpu调度”。
一旦获得CPU,线程就进入运行状态并自动调用自己的run方法。

提示:如果希望子线程调用start()方法后立即执行,可以使用Thread.sleep()方式使主线程睡眠一伙儿,转去执行子线程。

3、运行状态

处于运行状态的线程最为复杂,它可以变为阻塞状态、就绪状态和死亡状态。

处于就绪状态的线程,如果获得了cpu的调度,就会从就绪状态变为运行状态,执行run()方法中的任务。
如果该线程失去了cpu资源,就会又从运行状态变为就绪状态。重新等待系统分配资源。
也可以对在运行状态的线程调用yield()方法,它就会让出cpu资源,再次变为就绪状态。

注: 当发生如下情况是,线程会从运行状态变为阻塞状态:

​ ①、线程调用sleep方法主动放弃所占用的系统资源

​ ②、线程调用一个阻塞式IO方法,在该方法返回之前,该线程被阻塞

​ ③、线程试图获得一个同步监视器,但更改同步监视器正被其他线程所持有

​ ④、线程在等待某个通知(notify)

​ ⑤、程序调用了线程的suspend方法将线程挂起。不过该方法容易导致死锁,所以程序应该尽量避免使用该方法。

当线程的run()方法执行完,或者被强制性地终止,例如出现异常,或者调用了stop()、desyory()方法等等,就会从运行状态转变为死亡状态。

4、阻塞状态

处于运行状态的线程在某些情况下,如执行了sleep(睡眠)方法,或等待I/O设备等资源,将让出CPU并暂时停止自己的运行,进入阻塞状态。

在阻塞状态的线程不能进入就绪队列。只有当引起阻塞的原因消除时,如睡眠时间已到,或等待的I/O设备空闲下来,线程便转入就绪状态,重新到就绪队列中排队等待,被系统选中后从原来停止的位置开始继续运行。

5、死亡状态

当线程的run()方法执行完,或者被强制性地终止,就认为它死去。
这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。
线程一旦死亡,就不能复生。
如果在一个死去的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。

四、线程管理

线程管理

1、线程睡眠 sleep

如果我们需要让当前正在执行的线程暂停一段时间,并进入阻塞状态,则可以通过调用Thread的sleep方法。

  1. sleep是静态方法,最好不要用Thread的实例对象调用它,因为它睡眠的始终是当前正在运行的线程,而不是调用它的线程对象,它只对正在运行状态的线程对象有效。
  2. Java线程调度是Java多线程的核心,只有良好的调度,才能充分发挥系统的性能,提高程序的执行效率。
    但是不管程序员怎么编写调度,只能最大限度的影响线程执行的次序,而不能做到精准控制。
    因为使用sleep方法之后,线程是进入阻塞状态的,只有当睡眠的时间结束,才会重新进入到就绪状态,
    而就绪状态进入到运行状态,是由系统控制的,我们不可能精准的去干涉它,
    所以如果调用Thread.sleep(1000)使得线程睡眠1秒,可能结果会大于1秒。

sleep()可以在还没到睡眠结束时,强行唤醒,强行唤醒时,sleep方法要求处理中断异常

当一个线程调用sleep方法处于阻塞状态的过程中。此时线程被其他线程调用了该线程的interrupt方法

那么就会打断这个线程的睡眠阻塞,这时sleep方法就会抛出中断异常告知

2、线程让步 yield

yield()方法和sleep()方法有点相似,它也是Thread类提供的一个静态的方法,
它也可以让当前正在执行的线程暂停,让出cpu资源给其他的线程。

但是和sleep()方法不同的是,它不会进入到阻塞状态,而是进入到就绪状态。
yield()方法只是让当前线程暂停一下,重新进入就绪的线程池中,让系统的线程调度器重新调度器重新调度一次,完全可能出现这样的情况:当某个线程调用yield()方法之后,线程调度器又将其调度出来重新进入到运行状态执行。

实际上,当某个线程调用了yield()方法暂停之后,优先级与当前线程相同,或者优先级比当前线程更高的就绪状态的线程更有可能获得执行的机会,当然,只是有可能,因为我们不可能精确的干涉cpu调度线程。

public class Test1 {  
    public static void main(String[] args) throws InterruptedException {  
        new MyThread("低级", 1).start();  
        new MyThread("中级", 5).start();  
        new MyThread("高级", 10).start();  
    }  
}  
  
class MyThread extends Thread {  
    public MyThread(String name, int pro) {  
        super(name);// 设置线程的名称  
        this.setPriority(pro);// 设置优先级  
    }  
  
    @Override  
    public void run() {  
        for (int i = 0; i < 30; i++) {  
            System.out.println(this.getName() + "线程第" + i + "次执行!");  
            if (i % 5 == 0)  
                Thread.yield();  
        }  
    }  
} 

关于sleep()方法和yield()方的区别如下:

①、sleep方法暂停当前线程后,会进入阻塞状态,只有当睡眠时间到了,才会转入就绪状态。而yield方法调用后 ,是直接进入就绪状态,所以有可能刚进入就绪状态,又被调度到运行状态。

②、sleep方法声明抛出了InterruptedException,所以调用sleep方法的时候要捕获该异常,或者显示声明抛出该异常。而yield方法则没有声明抛出任务异常。

③、sleep方法比yield方法有更好的可移植性,通常不要依靠yield方法来控制并发线程的执行。

3、线程合并 join

线程的合并的含义就是将几个并行线程的线程合并为一个单线程执行,
应用场景是当一个线程必须等待另一个线程执行完毕才能执行时,
Thread类提供了join方法来完成这个功能,注意,它不是静态方法。

join方法允许当前线程在join方法所属线程上等待
直到该线程结束后结束join阻塞继续后续操作
所以join方法可以协调线程的同步运行

它有3个重载的方法:

void join()           
当前线程等该加入该线程后面,等待该线程终止。    
void join(long millis)       
当前线程等待该线程终止的时间最长为 millis 毫秒。 如果在millis时间内,该线程没有执行完,那么当前线程进入就绪状态,重新等待cpu调度  
void join(long millis,int nanos)        
等待该线程终止的时间最长为 millis 毫秒 + nanos 纳秒。如果在millis时间内,该线程没有执行完,那么当前线程进入就绪状态,重新等待cpu调度  

应用场景:

package thread;
public class JoinDemo {
	private static boolean isFinish = false;
	public static void main(String[] args) {
		Thread download = new Thread(){
			public void run(){
				System.out.println("down:开始下载图片");
				for(int i=1;i<=100;i++){
					System.out.println("down:"+i+"%");
					try {
						Thread.sleep(50);
					} catch (InterruptedException e) {
					}
				}
				System.out.println("down:图片下载完毕");
				isFinish = true;
			}
		};
		Thread show = new Thread(){
			public void run(){
				try{
					System.out.println("show:开始显示文字");
					Thread.sleep(3000);
					System.out.println("show:文字显示完毕!");
					System.out.println("show:开始显示图片");
					/*
					 * 先等待download线程执行完毕(图片下载完)
					 * 之后再继续后续操作
					 */
					download.join();
					if(!isFinish){
						throw new RuntimeException("图片加载失败");
					}
					System.out.println("图片显示成功");
				}catch(Exception e){

				}
			}
		};
		download.start();
		show.start();
	}
}

4、设置线程优先级

setPriority(pro);//设置线程的优先级

每个线程执行时都有一个优先级的属性,优先级高的线程可以获得较多的执行机会,而优先级低的线程则获得较少的执行机会。与线程休眠类似,线程的优先级仍然无法保障线程的执行次序。只不过,优先级高的线程获取CPU资源的概率较大,优先级低的也并非没机会执行。

每个线程默认的优先级都与创建它的父线程具有相同的优先级,在默认情况下,main线程具有普通优先级。

**注:**Thread类提供了setPriority(int newPriority)和getPriority()方法来设置和返回一个指定线程的优先级,其中setPriority方法的参数是一个整数,范围是1~·0之间,也可以使用Thread类提供的三个静态常量

MAX_PRIORITY   =10

MIN_PRIORITY   =1

NORM_PRIORITY   =5

**注:**虽然Java提供了10个优先级别,但这些优先级别需要操作系统的支持。不同的操作系统的优先级并不相同,而且也不能很好的和Java的10个优先级别对应。所以我们应该使用MAX_PRIORITY、MIN_PRIORITY和NORM_PRIORITY三个静态常量来设定优先级,这样才能保证程序最好的可移植性。

5、守护线程

守护线程使用的情况较少,但并非无用,举例来说,JVM的垃圾回收、内存管理等线程都是守护线程。
还有就是在做数据库应用时候,使用的数据库连接池,连接池本身也包含着很多后台线程,监控连接个数、超时时间、状态等等。(先了解一下,有个印象)
调用线程对象的方法setDaemon(true),则可以将其设置为守护线程。(在启动线程前设置就行了,比较简单)

守护线程的用途为:

​ • 守护线程通常用于执行一些后台作业,例如在你的应用程序运行时播放背景音乐,在文字编辑器里做自动语法检查、自动保存等功能。

​ • Java的垃圾回收也是一个守护线程。守护线的好处就是你不需要关心它的结束问题。例如你在你的应用程序运行的时候希望播放背景音乐,如果将这个播放背景音乐的线程设定为非守护线程,那么在用户请求退出的时候,不仅要退出主线程,还要通知播放背景音乐的线程退出;如果设定为守护线程则不需要了。

注:JRE判断程序是否执行结束的标准是所有的前台执线程行完毕了,而不管后台线程的状态,因此,在使用后台县城时候一定要注意这个问题

6、正确结束线程

Thread.stop()、Thread.suspend、Thread.resume、Runtime.runFinalizersOnExit
这些终止线程运行的方法已经被废弃了,使用它们是极端不安全的!
想要安全有效的结束一个线程,可以使用下面的方法:

​ • 正常执行完run方法,然后结束掉;

​ • 控制循环条件和判断条件的标识符来结束掉线程。

五、线程同步(锁机制)

同步运行:多个线程之间执行有顺序

异步运行:多个线程之间各自执行各自的

java允许多线程并发控制,当多个线程同时操作一个可共享的资源变量时(如数据的增删改查)
将会导致数据不准确,相互之间产生冲突
因此加入同步锁以避免在该线程没有完成操作之前,被其他线程的调用
从而保证了该变量的唯一性和准确性

1、synchronized关键字

这个关键字添加锁标记的为对象,是操作的对象,
线程只是在运行前看看这个对象有没有锁标记,有的话就会被阻塞

修饰方法时,添加锁标记的 为调用当前方法的实例化对象(this)
修饰代码块时,添加锁标记的 为小括号里传入的同步监视器对象

synchronized的三个用处:

①同步方法

synchronized 关键字修饰方法时,一次只能有一个线程运行,多个线程不能同时在方法内部运行,其他要运行的线程会阻塞

使用方法:在定义方法时,类似于添加修饰符一样,加在该方法前即可

需要注意的是锁的对象是,调用该方法的对象

所以如果锁的是静态方法,锁的是类对象,那么不管是该类的哪个对象,都会上锁

②同步块

synchronized 关键字修饰代码块时,有效缩小同步范围可以在保证并发安全的前提下尽可能的提高并发的效率

synchronized(同步监视器对象){
    需要多线程同步运行的代码片段
}

同步块可以更准确的锁定需要同步运行的代码片段,从而有效控制同步范围

同步块更灵活与准确,注意要指定同步监视对象

synchronized会给当前传入的同步监视器对象上锁(添加锁标记)

使用同步块时要注意

多个需要同步该代码片段的线程看到的同步监视器对象必须是同一个

啥意思呢?

就是说我不能这么干

synchronized(new Object){
}

我锁的对象是我临时new出来的一个新对象,每个线程执行到这里都会new一个新对象,并给这个新对象上锁,这就没有同步意义了

③互斥锁

当使用synchronized锁定多个片段,并且指定的同步监视器对象是同一个时

这些代码片段间就是互斥的,多个线程不能同时在这些代码片段间一起执行

锁标记打在对象上,被synchronized锁定的方法片段,被访问时,会查看对象有没有锁标记

互斥锁怎么理解呢?

就是同步锁锁了多个片段,这些片段锁的对象也是同一个,那么这些片段就是互斥的,只能一个一个执行

2、使用特殊域变量(volatile)实现线程同步

• volatile关键字为域变量的访问提供了一种免锁机制;

• 使用volatile修饰域相当于告诉虚拟机该域可能会被其他线程更新;

• 因此每次使用该域就要重新计算,而不是使用寄存器中的值;

• volatile不会提供任何原子操作,它也不能用来修饰final类型的变量。

private volatile Map<string,String> map = new HashMap<>();

这个关键字为可以理解为修饰符,用于修饰变量
将多个线程可能操作的变量用该关键字修饰,会让不能有多个线程同时操作该变量

volatile和synchronized详解

参考:https://www.cnblogs.com/kaleidoscope/p/9506018.html

volatile和synchronized特点

首先需要理解线程安全的两个方面:执行控制和内存可见。

执行控制的目的是控制代码执行(顺序)及是否可以并发执行。

内存可见控制的是线程执行结果在内存中对其它线程的可见性。根据Java内存模型的实现,线程在具体执行时,会先拷贝主存数据到线程本地(CPU缓存),操作完成后再把结果从线程本地刷到主存。

synchronized关键字解决的是执行控制的问题,它会阻止其它线程获取当前对象的监控锁,这样就使得当前对象中被synchronized关键字保护的代码块无法被其它线程访问,也就无法并发执行。更重要的是,synchronized还会创建一个内存屏障,内存屏障指令保证了所有CPU操作结果都会直接刷到主存中,从而保证了操作的内存可见性,同时也使得先获得这个锁的线程的所有操作,都happens-before于随后获得这个锁的线程的操作。

volatile关键字解决的是内存可见性的问题,会使得所有对volatile变量的读写都会直接刷到主存,即保证了变量的可见性。这样就能满足一些对变量可见性有要求而对读取顺序没有要求的需求。

使用volatile关键字仅能实现对原始变量(如boolen、 short 、int 、long等)操作的原子性,但需要特别注意, volatile不能保证复合操作的原子性,即使只是i++,实际上也是由多个原子操作组成:read i; inc; write i,假如多个线程同时执行i++,volatile只能保证他们操作的i是同一块内存,但依然可能出现写入脏数据的情况。

在Java 5提供了原子数据类型atomic wrapper classes,对它们的increase之类的操作都是原子操作,不需要使用sychronized关键字。

对于volatile关键字,当且仅当满足以下所有条件时可使用:

  1. 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
  2. 该变量没有包含在具有其他变量的不变式中。
volatile和synchronized的区别

volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取; synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
volatile仅能使用在变量级别;synchronized则可以使用在变量、方法、和类级别的
volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量的修改可见性和原子性
volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞。
volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化

3、用重入锁(Lock)实现线程同步

ps.可重入锁

可重入锁是锁的一个相关概念,并不是特指我们的ReentrantLock,而是如果一个锁具备可重入性,那我们就说这是一个可重入锁。ReentrantLock和synchronized都是可重入锁。至于什么是可重入性,这里举个简单的例子,现在在一个类里我们有两个方法(代码如下),一个叫做去北京,一个叫做买票,那我们在去北京的方法里可以直接调用买票方法,假如两个方法全都用synchronized修饰的话,在执行去北京的方法,线程获取了对象的锁,接着执行买票方法,如果synchronized不具备可重入性,那么线程已经有这个对象的锁了,现在又来申请,就会导致线程永远等待无法获取到锁。而synchronized和ReentrantLock都是可重入锁,就不会出现上述的问题。

在JavaSE5.0中新增了一个java.util.concurrent包来支持同步。

ReentrantLock类是可重入、互斥、实现了Lock接口的锁,它与使用synchronized方法和快具有相同的基本行为和语义,并且扩展了其能力。

ReenreantLock类的常用方法有:

 ReentrantLock() : 创建一个ReentrantLock实例         
 lock() : 获得锁        
 unlock() : 释放锁

**注:**ReentrantLock()还有一个可以创建公平锁的构造方法,但由于能大幅度降低程序运行效率,不推荐使用

Lock与synchronized的不同

二者都是可重入锁,那么为什么要有两个呢?既然存在,那么就一定是有意义的。
synchronized是Java中的一个关键字,而Lock是Java1.5后在java.util.concurrent.locks包下提供的一种实现同步的方法,那么显然的,synchronized一定是有什么做不到的或者缺陷,才导致了Lock的诞生。

synchronized的缺点
  1. 当一个代码块被synchronized修饰的时候,一个线程获取到了锁,并且执行代码块,那么其他的线程需要等待正在使用的线程释放掉这个锁,那么释放锁的方法只有两种,一种是代码执行完毕自动释放,一种是发生异常以后jvm会让线程去释放锁。那么如果这个正在执行的线程遇到什么问题,比如等待IO或者调用sleep方法等等被阻塞了,无法释放锁,而这时候其他线程只能一直等待,将会特别影响效率。那么有没有一种办法让其他线程不必一直傻乎乎的等在这里吗?

  2. 当一个文件,同时被多个线程操作时,读操作和写操作会发生冲突,写操作和写操作会发生冲突,而读操作和读操作并不会冲突,但是如果我们用synchronized的话,会导致一个线程在读的时候,其他线程想要读的话只能等待,那么有什么办法能不锁读操作吗?

  3. 在使用synchronized时,我们无法得知线程是否成功获取到锁,那么有什么办法能知道是否获取到锁吗?

Lock的相关方法

ReentrantLock是Lock的一个实现类(另外两个实现类是ReentrantReadWriteLock类下的两个静态内部类:WriteLock和ReadLock),它的意思是可重入锁,可重入锁前面已经讲过了。ReentrantLock中提供了更多的一些方法。不过常用的就是Lock中的这些。

来看一下Lock接口这些方法的使用,lock()、tryLock()、tryLock(long time, TimeUnit unit)和lockInterruptibly()是用来获取锁的。unLock()方法是用来释放锁的。

这里有四个方法来获取锁,那么区别在哪里呢?

  1. lock()使我们平时用的最多的,最用是用来获取锁,如果锁已经被其他线程获取,那么就等待。但是采用Lock必须要主动释放锁,所以我们一般在try{}catch{}块中处理然后在finally中释放锁,

  2. tryLock()是一个boolean类型的方法,当调用这个方法的时候,线程会去尝试获取锁,如果获取到的话会返回true,如果获取不到返回false,也就是说这个方法会立马返回一个结果,线程不会等待。

  3. tryLock(long time, TimeUnit unit)是上面tryLock()方法的一个重载方法,加了两个参数,给定了等待的时间,如果在规定时间拿到锁返回true,如果拿不到返回false。这两个方法的一般用法和Lock类似。

  4. lockInterruptibly()就比较特殊了,它表示可被中断的,意思就是,当尝试获取锁的时候,如果获取不到的话就会等待,但是,在等待的过程中它是可以响应中断的,也就是中断线程的等待过程。使用形式的话一样用try catch处理,就不贴代码了。

ReadWriteLock

ReadWriteLock也是一个接口,这个接口中只有两个方法,源码如下:

1 public interface ReadWriteLock {
2     Lock readLock();
3  
4     Lock writeLock();
5 }

这个接口的从字面就能看出来他的用途,读锁和写锁

ReentrantReadWriteLock是ReadWriteLock的一个实现类,最常用到的也是获取读锁和获取写锁。

所以关于synchronized与Lock的区别:

1.synchronized是Java的关键字,是内置特性,而Lock是一个接口,可以用它来实现同步。

2.synchronized同步的时候,其中一条线程用完会自动释放锁,而Lock需要手动释放,如果不手动释放,可能会造成死锁。

3.使用synchronized如果其中一个线程不释放锁,那么其他需要获取锁的线程会一直等待下去,知道使用完释放或者出现异常,而Lock可以使用可以响应中断的锁或者使用规定等待时间的锁

4.synchronized无法得知是否获取到锁,而Lcok可以做到。

5.用ReadWriteLock可以提高多个线程进行读操作的效率。

综上所述,在两种锁的选择上,当线程对于资源的竞争不激烈的时候,效率差不太多,但是当大量线程同时竞争的时候,Lock的性能会远高于synchronized

六、线程通信

所谓线程通信,就是数据在多个线程之间的流通,多线程操作同一数据

1、借助于Object类的wait()、notify()和notifyAll()实现通信

线程执行wait()后,就放弃了运行资格,处于冻结状态;

线程运行时,内存中会建立一个线程池,冻结状态的线程都存在于线程池中,notify()执行时唤醒的也是线程池中的线程,线程池中有多个线程时唤醒第一个被冻结的线程。

notifyall(), 唤醒线程池中所有线程。

注: (1) wait()、notify()、notifyall() 都用在同步里面,因为这3个函数是对持有锁的线程进行操作,而只有同步才有锁,所以要使用在同步中;
(2) wait()、notify()、notifyall() 在使用时必须标识它们所操作的线程持有的锁,因为等待和唤醒必须是同一锁下的线程;而锁可以是任意对象,所以这3个方法都是Object类中的方法。

单个消费者生产者例子如下:

 class Resource{  //生产者和消费者都要操作的资源  
    private String name;  
    private int count=1;  
    private boolean flag=false;  
    public synchronized void set(String name){  
        if(flag)  
            try{wait();}catch(Exception e){}  
        this.name=name+"---"+count++;  
        System.out.println(Thread.currentThread().getName()+"...生产者..."+this.name);  
        flag=true;  
        this.notify();  
    }  
    public synchronized void out(){  
        if(!flag)  
            try{wait();}catch(Exception e){}  
        System.out.println(Thread.currentThread().getName()+"...消费者..."+this.name);  
        flag=false;  
        this.notify();  
    }  
}  
class Producer implements Runnable{  
    private Resource res;  
    Producer(Resource res){  
        this.res=res;  
    }  
    public void run(){  
        while(true){  
            res.set("商品");  
        }  
    }  
}  
class Consumer implements Runnable{  
    private Resource res;  
    Consumer(Resource res){  
        this.res=res;  
    }  
    public void run(){  
        while(true){  
            res.out();  
        }  
    }  
}  
public class ProducerConsumerDemo{  
    public static void main(String[] args){  
        Resource r=new Resource();  
        Producer pro=new Producer(r);  
        Consumer con=new Consumer(r);  
        Thread t1=new Thread(pro);  
        Thread t2=new Thread(con);  
        t1.start();  
        t2.start();  
    }  
}//运行结果正常,生产者生产一个商品,紧接着消费者消费一个商品。

​ 但是如果有多个生产者和多个消费者,上面的代码是有问题,比如2个生产者,2个消费者,运行结果就可能出现生产的1个商品生产了一次而被消费了2次,或者连续生产2个商品而只有1个被消费,这是因为此时共有4个线程在操作Resource对象r, 而notify()唤醒的是线程池中第1个wait()的线程,所以生产者执行notify()时,唤醒的线程有可能是另1个生产者线程,这个生产者线程从wait()中醒来后不会再判断flag,而是直接向下运行打印出一个新的商品,这样就出现了连续生产2个商品。
为了避免这种情况,修改代码如下:

    class Resource{  
        private String name;  
        private int count=1;  
        private boolean flag=false;  
        public synchronized void set(String name){  
            while(flag) /*原先是if,现在改成while,这样生产者线程从冻结状态醒来时,还会再判断flag.*/  
                try{wait();}catch(Exception e){}  
            this.name=name+"---"+count++;  
            System.out.println(Thread.currentThread().getName()+"...生产者..."+this.name);  
            flag=true;  
            this.notifyAll();/*原先是notity(), 现在改成notifyAll(),这样生产者线程生产完一个商品后可以将等待中的消费者线程唤醒,否则只将上面改成while后,可能出现所有生产者和消费者都在wait()的情况。*/  
        }  
        public synchronized void out(){  
            while(!flag) /*原先是if,现在改成while,这样消费者线程从冻结状态醒来时,还会再判断flag.*/  
                try{wait();}catch(Exception e){}  
            System.out.println(Thread.currentThread().getName()+"...消费者..."+this.name);  
            flag=false;  
            this.notifyAll(); /*原先是notity(), 现在改成notifyAll(),这样消费者线程消费完一个商品后可以将等待中的生产者线程唤醒,否则只将上面改成while后,可能出现所有生产者和消费者都在wait()的情况。*/  
        }  
    }  
    public class ProducerConsumerDemo{  
        public static void main(String[] args){  
            Resource r=new Resource();  
            Producer pro=new Producer(r);  
            Consumer con=new Consumer(r);  
            Thread t1=new Thread(pro);  
            Thread t2=new Thread(con);  
            Thread t3=new Thread(pro);  
            Thread t4=new Thread(con);  
            t1.start();  
            t2.start();  
            t3.start();  
            t4.start();  
        }  
    }  

2、使用Condition控制线程通信

jdk1.5中,提供了多线程的升级解决方案为:

​ (1)将同步synchronized替换为显式的Lock操作;

​ (2)将Object类中的wait()、notify()、notifyAll()替换成了Condition对象,该对象可以通过Lock锁对象获取;

​ (3)一个Lock对象上可以绑定多个Condition对象,这样实现了本方线程只唤醒对方线程,而jdk1.5之前,一个同步只能有一个锁,不同的同步只能用锁来区分,且锁嵌套时容易死锁。

    class Resource{  
        private String name;  
        private int count=1;  
        private boolean flag=false;  
        private Lock lock = new ReentrantLock();/*Lock是一个接口,ReentrantLock是该接口的一个直接子类。*/  
        private Condition condition_pro=lock.newCondition(); /*创建代表生产者方面的Condition对象*/  
        private Condition condition_con=lock.newCondition(); /*使用同一个锁,创建代表消费者方面的Condition对象*/  
          
        public void set(String name){  
            lock.lock();//锁住此语句与lock.unlock()之间的代码  
            try{  
                while(flag)  
                    condition_pro.await(); //生产者线程在conndition_pro对象上等待  
                this.name=name+"---"+count++;  
                System.out.println(Thread.currentThread().getName()+"...生产者..."+this.name);  
                flag=true;  
                 condition_con.signalAll();  
            }  
            finally{  
                lock.unlock(); //unlock()要放在finally块中。  
            }  
        }  
        public void out(){  
            lock.lock(); //锁住此语句与lock.unlock()之间的代码  
            try{  
                while(!flag)  
                    condition_con.await(); //消费者线程在conndition_con对象上等待  
            System.out.println(Thread.currentThread().getName()+"...消费者..."+this.name);  
            flag=false;  
            condition_pro.signqlAll(); /*唤醒所有在condition_pro对象下等待的线程,也就是唤醒所有生产者线程*/  
            }  
            finally{  
                lock.unlock();  
            }  
        }  
    }  

3、使用阻塞队列(BlockingQueue)控制线程通信

BlockingQueue是一个接口,也是Queue的子接口。

**BlockingQueue具有一个特征:**当生产者线程试图向BlockingQueue中放入元素时,如果该队列已满,则线程被阻塞;但消费者线程试图从BlockingQueue中取出元素时,如果队列已空,则该线程阻塞。

程序的两个线程通过交替向BlockingQueue中放入元素、取出元素,即可很好地控制线程的通信。

BlockingQueue提供如下两个支持阻塞的方法:

**(1)put(E e):**尝试把Eu元素放如BlockingQueue中,如果该队列的元素已满,则阻塞该线程。

**(2)take():**尝试从BlockingQueue的头部取出元素,如果该队列的元素已空,则阻塞该线程。

BlockingQueue继承了Queue接口,当然也可以使用Queue接口中的方法,这些方法归纳起来可以分为如下三组:

**(1)**在队列尾部插入元素,包括add(E e)、offer(E e)、put(E e)方法,当该队列已满时,这三个方法分别会抛出异常、返回false、阻塞队列。

**(2)**在队列头部删除并返回删除的元素。包括remove()、poll()、和take()方法,当该队列已空时,这三个方法分别会抛出异常、返回false、阻塞队列。

**(3)**在队列头部取出但不删除元素。包括element()和peek()方法,当队列已空时,这两个方法分别抛出异常、返回false。

BlockingQueue接口包含如下5个实现类:

ArrayBlockingQueue :基于数组实现的BlockingQueue队列。

LinkedBlockingQueue:基于链表实现的BlockingQueue队列。

PriorityBlockingQueue:它并不是保准的阻塞队列,该队列调用remove()、poll()、take()等方法提取出元素时,并不是取出队列中存在时间最长的元素,而是队列中最小的元素。                       它判断元素的大小即可根据元素(实现Comparable接口)的本身大小来自然排序,也可使用Comparator进行定制排序。

SynchronousQueue:同步队列。对该队列的存、取操作必须交替进行。

DelayQueue:它是一个特殊的BlockingQueue,底层基于PriorityBlockingQueue实现,不过,DelayQueue要求集合元素都实现Delay接口(该接口里只有一个long getDelay()方法),            DelayQueue根据集合元素的getDalay()方法的返回值进行排序。

copy的一个示例:

 1 import java.util.concurrent.ArrayBlockingQueue;
 2 import java.util.concurrent.BlockingQueue;
 3 public class BlockingQueueTest{
 4     public static void main(String[] args)throws Exception{
 5         //创建一个容量为1的BlockingQueue
 6         
 7         BlockingQueue<String> b=new ArrayBlockingQueue<>(1);
 8         //启动3个生产者线程
 9         new Producer(b).start();
10         new Producer(b).start();
11         new Producer(b).start();
12         //启动一个消费者线程
13         new Consumer(b).start();
14         
15     }
16 }
17 class Producer extends Thread{
18     private BlockingQueue<String> b;
19     
20     public Producer(BlockingQueue<String> b){
21         this.b=b;
22         
23     }
24     public synchronized void run(){
25         String [] str=new String[]{
26             "java",
27             "struts",
28             "Spring"
29         };
30         for(int i=0;i<9999999;i++){
31             System.out.println(getName()+"生产者准备生产集合元素!");
32             try{
33             
34                 b.put(str[i%3]);
35                 sleep(1000);
36                 //尝试放入元素,如果队列已满,则线程被阻塞
37                 
38             }catch(Exception e){System.out.println(e);}
39             System.out.println(getName()+"生产完成:"+b);
40         }
41         
42     }
43 }
44 class Consumer extends Thread{
45     private BlockingQueue<String> b;
46     public Consumer(BlockingQueue<String> b){
47         this.b=b;
48     }
49     public  synchronized  void run(){
50     
51         while(true){
52             System.out.println(getName()+"消费者准备消费集合元素!");
53             try{
54                 sleep(1000);
55                 //尝试取出元素,如果队列已空,则线程被阻塞
56                 b.take();
57             }catch(Exception e){System.out.println(e);}
58             System.out.println(getName()+"消费完:"+b);
59         }
60     
61     }
62 }

ps.同一个OS中的进程之间的通讯方案有哪些?

原文链接:https://blog.csdn.net/QQQZSJ/article/details/100838773

低级通信方式:PV操作
高级通信方式:指以较高的效率传输大量数据的通信方式

高级通信方式主要有以下三类:

1、共享存储(存储空间)

在通信进程之间存在一块可以直接访问的共享存储空间,通过对该共享空间进行读/写操作实现信息交换。

注意 用户进程空间一般都是独立的,进程运行期间一般不能访问其他进程的空间,要想让两个用户进程共享空间,必须通过特殊的系统调用来实现,而进程内的线程是自然共享进程空间的。

形象解释:甲和乙中间有一个大布袋,甲和乙的物品交换通过这个大布袋进行,甲把物品放在大布袋里,乙拿走。但是乙不能直接到甲的手里去拿东西,甲也不能到乙手中直接拿东西。

2、消息传递(进程间的数据交换是以格式化的消息为单位的)

如通信的进程之间不存在可以直接访问的共享空间,则必须利用操作系统提供的消息传递方法实现进程间的通信。

进程通过系统提供的发送消息和接收消息的两个原语进行数据交换。

1)直接通信方式:发送进程直接把消息发送给接收进程,并将它挂在接收进程的消息缓冲队列上,接收进程从该消息缓冲队列上取得消息。

2)间接通信方式(信箱通信方式):发送进程把消息发送到一个中间实体(我们称之为信箱),接收进程从信箱中取得消息。

3、管道通信(一种特殊的通信方式)

“管道”:用于连接一个读进程和一个写进程以实现它们之间的通信的一个共享文件,又名pipe文件。

向管道(共享文件)提供输入的发送进程(即写进程),以字符流形式将大量的数据送入(写)管道;而接收管道输出的接收进程(即读进程)则从管道中直接接收(读)数据的过程,为管道通信。

为了协调双方的通信,管道必须提供三个方面的协调能力:互斥、同步、确定双方的存在。

注意
  1. 管道是一种文件,与一般文件稍有不同,其可以克服使用文件进行通信的两个问题:
    a)限制管道的大小 b)读进程也可能工作的比写进程快

  2. 从管道读数据是一一次性操作,数据一旦被读取,它就从管道中被抛弃,释放空间以便写入更多的数据。

  3. 管道可以看成是共享存储空间的进化版,但是由于在管道通信中,存储空间进化成了缓冲区,缓冲区只能一边写入、另一边读出,因此只要缓冲区中有数据,进程就能从缓冲区中把数据读出,而不必担心因为其他进程在其中进行写操作而遭到阻塞,因为写进程会先把缓冲区写满,然后才让读进程读取,所以当缓冲却还有数据时,写进程是不会往缓冲区写数据的,因此,我们得出第三个结论:管道通信必然是半双工通信。

七、线程池

线程池是管理线程的一套解决方案

线程池是一种多线程处理形式,处理过程中将任务添加到队列,然后在创建线程后自动启动这些任务

线程池的作用

  1. 控制线程数量
    线程数量过多会导致过多的资源消耗
    并且会导致CPU过度切换,降低整体并发性能
  2. 重用线程
    线程不应当随着任务的生命周期一致
    频繁的创建和销毁线程也会给系统带来额外的开销

创建线程池

1.使用Executors工厂类产生线程池

ExecutorService为线程池的类
我们通过Executors.newFixedThreadPool() 方法创建线程池,创建固定数量的线程池
该方法需要规定容纳的线程数量,传入参数即可,具体多少线程根据机器的实际负载能力而得
有多少任务就会创建多少个线程,最多创建设定的个数

ExecutorService有如下几个执行方法

execute(Runnable)
这个方法接收一个Runnable实例,并且异步的执行

submit(Runnable)
submit(Runnable)和execute(Runnable)区别是前者可以返回一个Future对象
通过返回的Future对象,我们可以检查提交的任务是否执行完毕

**注:**如果任务执行完成,future.get()方法会返回一个null。注意,future.get()方法会产生阻塞。

submit(Callable)
submit(Callable)和submit(Runnable)类似,也会返回一个Future对象
但是除此之外,submit(Callable)接收的是一个Callable的实现,Callable接口中的call()方法有一个返回值
可以返回任务的执行结果,而Runnable接口中的run()方法是void的,没有返回值。
如果任务执行完成,future.get()方法会返回Callable任务的执行结果。另外,future.get()方法会产生阻塞。

invokeAny(…)
invokeAny(...)方法接收的是一个Callable的集合,执行这个方法不会返回Future,
但是会返回所有Callable任务中其中一个任务的执行结果。
这个方法也无法保证返回的是哪个任务的执行结果,反正是其中的某一个。

invokeAll(…)
invokeAll(...)invokeAny(...)类似也是接收一个Callable集合,
但是前者执行之后会返回一个Future的List,其中对应着每个Callable任务执行后的Future对象。

停止线程池

shutdown() 停止线程池,但是不是立即停,而是等待线程池中线程跑完所有任务后停止线程池
shutdownNow() 立即停止线程池,手里活没干完就停止,属于强行停止线程,会报异常

注意:
频繁的创建和销毁线程也会给系统带来额外的开销

public class ThreadPoolDemo {
	public static void main(String[] args) {
		//创建一个固定大小的线程池
		ExecutorService threadPool = Executors.newFixedThreadPool(2);
		/*
		 * 可以创建多个线程任务,但是只有两个线程在被调度
		 * 这两个线程跑完两个线程任务,就换新任务给这两个线程
		 */
		for(int i=0;i<5;i++){
			Runnable r = new Runnable(){
				public void run() {
					try {
						Thread t = Thread.currentThread();
						System.out.println(t.getName()+"正在执行任务(((");
						Thread.sleep(2000);
						System.out.println(t.getName()+"执行任务完毕)))");
					} catch (Exception e) {
						e.printStackTrace();
					}
				}
			};
			threadPool.execute(r);
			System.out.println("指派的第"+i+"个任务");
		}
		//shutdown()  停止线程池,但是不是立即停,而是等待线程池中线程跑完所有任务后停止线程池
		//threadPool.shutdown();
		//shutdownNow()  立即停止线程池,手里活没干完就停止,属于强行停止线程,会报异常
		threadPool.shutdownNow();
		System.out.println("线程池停止了 ");
	}
}

2.使用Java8增强的ForkJoinPool产生线程池

​ 在Java 8中,引入了自动并行化的概念。它能够让一部分Java代码自动地以并行的方式执行,前提是使用了ForkJoinPool。

​ ForkJoinPool同ThreadPoolExecutor一样,也实现了Executor和ExecutorService接口。它使用了一个无限队列来保存需要执行的任务,而线程的数量则是通过构造函数传入,如果没有向构造函数中传入希望的线程数量,那么当前计算机可用的CPU数量会被设置为线程数量作为默认值。

​ ForkJoinPool主要用来使用分治法(Divide-and-Conquer Algorithm)来解决问题。典型的应用比如快速排序算法。这里的要点在于,ForkJoinPool需要使用相对少的线程来处理大量的任务。比如要对1000万个数据进行排序,那么会将这个任务分割成两个500万的排序任务和一个针对这两组500万数据的合并任务。以此类推,对于500万的数据也会做出同样的分割处理,到最后会设置一个阈值来规定当数据规模到多少时,停止这样的分割处理。比如,当元素的数量小于10时,会停止分割,转而使用插入排序对它们进行排序。那么到最后,所有的任务加起来会有大概2000000+个。问题的关键在于,对于一个任务而言,只有当它所有的子任务完成之后,它才能够被执行。所以当使用ThreadPoolExecutor时,使用分治法会存在问题,因为ThreadPoolExecutor中的线程无法像任务队列中再添加一个任务并且在等待该任务完成之后再继续执行。而使用ForkJoinPool时,就能够让其中的线程创建新的任务,并挂起当前的任务,此时线程就能够从队列中选择子任务执行。

我们需要统计一个double数组中小于0.5的元素的个数,那么可以使用ForkJoinPool进行实现如下

public class ForkJoinTest {
    private double[] d;
    private class ForkJoinTask extends RecursiveTask {
        private int first;
        private int last;
        public ForkJoinTask(int first, int last) {
            this.first = first;
            this.last = last;
        }
        protected Integer compute() {
            int subCount;
            if (last - first < 10) {
                subCount = 0;
                for (int i = first; i <= last; i++) {
                    if (d[i] < 0.5){
                        subCount++;
                     }
                }
             }else {
                int mid = (first + last) /2;
                ForkJoinTask left = new ForkJoinTask(first, mid);
                left.fork();
                ForkJoinTask right = new ForkJoinTask(mid + 1, last);
                right.fork();
                subCount = left.join();
                subCount += right.join();
            }
            return subCount;
        }
    }
    public static void main(String[] args) {
        ForkJoinPool pool=new ForkJoinPool();
        pool.submit(new ForkJoinTask(0, 9999999));
        pool.awaitTermination(2,TimeUnit.SECONDS);
        System.out.println("Found " + n + " values");
    }
}

以上的关键是fork()和join()方法。在ForkJoinPool使用的线程中,会使用一个内部队列来对需要执行的任务以及子任务进行操作来保证它们的执行顺序。

**注:**使用ThreadPoolExecutor和ForkJoinPool的性能差异:

(1)首先,使用ForkJoinPool能够使用数量有限的线程来完成非常多的具有父子关系的任务,比如使用4个线程来完成超过200万个任务。但是,使用ThreadPoolExecutor时,是不可能完成的,因为ThreadPoolExecutor中的Thread无法选择优先执行子任务,需要完成200万个具有父子关系的任务时,也需要200万个线程,显然这是不可行的。

(2)ForkJoinPool能够实现工作窃取(Work Stealing),在该线程池的每个线程中会维护一个队列来存放需要被执行的任务。当线程自身队列中的任务都执行完毕后,它会从别的线程中拿到未被执行的任务并帮助它执行。因此,提高了线程的利用率,从而提高了整体性能。

(3)对于ForkJoinPool,还有一个因素会影响它的性能,就是停止进行任务分割的那个阈值。比如在之前的快速排序中,当剩下的元素数量小于10的时候,就会停止子任务的创建。

结论
  1. 当需要处理递归分治算法时,考虑使用ForkJoinPool;
  2. 仔细设置不再进行任务划分的阈值,这个阈值对性能有影响;
  3. Java 8中的一些特性会使用到ForkJoinPool中的通用线程池。在某些场合下,需要调整该线程池的默认的线程数量。

八、死锁

产生死锁的四个必要条件如下。当下边的四个条件都满足时即产生死锁,即任意一个条件不满足既不会产生死锁。

(1)死锁的四个必要条件

  • 互斥条件:资源不能被共享,只能被同一个进程使用
  • 请求与保持条件:已经得到资源的进程可以申请新的资源
  • 非剥夺条件:已经分配的资源不能从相应的进程中被强制剥夺
  • 循环等待条件:系统中若干进程组成环路,该环路中每个进程都在等待相邻进程占用的资源

​ 举个常见的死锁例子:进程A中包含资源A,进程B中包含资源B,A的下一步需要资源B,B的下一步需要资源A,所以它们就互相等待对方占有的资源释放,所以也就产生了一个循环等待死锁。

(2)处理死锁的方法

  • 忽略该问题,也即鸵鸟算法。当发生了什么问题时,不管他,直接跳过,无视它;
  • 检测死锁并恢复;
  • 资源进行动态分配;
  • 破除上面的四种死锁条件之一。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值