Java并发以及多线程基础
进程与线程
进程是操作系统用来资源分配的最小单位,线程是CPU调度执行的最小单位。
为什么要有线程?两者区别?
因为进程的创建、销毁、切换产生大量的开销,而线程是比进程更小的能独立运行的基本单位,他是进程的一个实体,可以减少程序并发执行时的时间和空间开销,使得操作系统具有更好的并发性。
进程有自己独立的地址空间,线程没有;
线程占用的资源比进程少很多
线程之间通信更方便,而进程之间通信比较困难
什么是线程安全?
如果你的代码在多线程下执行和在单线程下执行永远都能获得一样的结果,那么你的代码就是线程安全的。
创建线程的三种方式:
通过继承Thread接口,并重写该类的run()方法
public class Mytheard1 extends Thread{}
通过实现Runnable接口,并重写该类的run()方法
public class Mytheard2 implements Runnable{}
通过实现Callable接口,并重写该类的call()方法
public class Mytheard3 implements Callable<Integer>
更推荐通过实现 Runnable
接口和实现 Callable接口
,因为面向接口编程拓展性更好,而且可以防止 java 单继承的限制。
- 启动上面三个线程
public static void main(String[] args) throws InterruptedException {
long startTime = System.currentTimeMillis();
// 通过主线程启动自己的线程
// 通过继承 thread 类
Mytheard1 thread1 = new Mytheard1();
thread1.start(); //可直接写成new Mytheard1().start();
// 通过实现 runnable 接口
Thread thread2 = new Thread(new Mytheard2());
thread2.start(); //可直接写成new Mytheard(new Mytheard2()).start();
// 通过实现 callable 接口
Mytheard3 thread3 = new Mytheard3();
FutureTask<Integer> result = new FutureTask<>(thread3);
new Thread(result).start();
// 注意这里都不是直接调用 run()方法,而是调运线程类 Thread 的 start 方法,在 Thread 方法内部,会调运本地系统方法,最终会自动调运自己线程类的 run方法
// 让主线程睡眠
Thread.sleep(1000L);
System.out.println("主线程结束!用时:" + (System.currentTimeMillis() - startTime)); }
Runnable接口和Callable接口的区别
Runnable接口中的run()方法的返回值是void,它做的事情只是纯粹地去执行run()方法中的代码而已;Callable接口中的call()方法是有返回值的,是一个泛型,和Future、FutureTask配合可以用来获取异步执行的结果。
- 这其实是很有用的一个特性,因为多线程相比单线程更难、更复杂的一个重要原因就是因为多线程充满着未知性,某条线程是否执行了?某条线程执行了多久?某条线程执行的时候我们期望的数据是否已经赋值完毕?无法得知,我们能做的只是等待这条多线程的任务执行完毕而已。而Callable+Future/FutureTask却可以获取多线程运行的结果,可以在等待时间太长没获取到需要的数据的情况下取消该线程的任务
守护线程
可以通过 thread.setDaemon(true)
方法设置线程是否为守护线程, 如果把一个线程设置成守护线程,则 jvm 的退出就不会关心当前线程的执行状态。
线程的常用使用方法:
setPriority(int )
:设置优先级1-10,先设置优先级,再start()启动。
sleep(long)
:让正在执行的线程休眠指定毫秒数(不释放锁抱着锁睡觉)sleep时间达到后进入就绪状态
wait()
:让当前的线程等待,会释放锁,直到其他线程调用此对象的notify( ) 方法或 notifyAll( ) 方法
notify()
:唤醒在此对象监视器上等待的单个线程
—wait( ),notify( ),notifyAll( )都不属于Thread类,而是属于Object基础类
join()
:插队,等待该线程执行完,其他线程才能执行(少使用!)
yield()
:礼让,从运行回到就绪状态,重新和就绪线程抢夺时间片
interrupt()
:中断线程(少使用!),推荐设置flag标志位
isAlive()
:检测线程是否处于活跃状态
并发中的乐观锁和悲观锁
乐观锁:
每次操作共享数据的时候,认为数据都不会造成冲突,别人不会去修改,所以每次获取数据的时候都不会进行加锁,但是在更新数据的时候需要判断该数据是否被别人修改过。如果数据被其他线程修改,则不进行数据更新,如果数据没有被其他线程修改,则进行数据更新。
由于数据没有进行加锁,期间该数据可以被其他线程进行读写操作 (乐观锁的一种实现方式CAS
实现的)
CAS即Compare and Swap有 3 个操作数,内存变量地址 V,旧的预期值 A,要修改的新值 B。
当且仅当预期值 A 和内存值 V相同时,将内存值 V 修改为 B,否则什么都不做
CAS的全称是compare-and-swap,即比较和交换。虽然看起来的先比较再交换,无法保证原子性,其实其利用的是底层硬件,是一条CPU的原子指令,是线程安全的。
—(CAS扩展:ABA问题-加版本号(AtomicStampedReference类)、如何保证cas操作原子性?:使用【锁总线】单核处理器没有这个问题,多核处理器就会在总线加lock cmpxchg命令:锁住总线,在cpu完成这条修改指令前,不允许别的CPU干预)(带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。)
悲观锁:
每次获取数据的时候,都会担心数据被修改,所以每次获取数据的时候都会进行加锁,确保在自己使用的过程中数据不会被别人修改,在此期间,其他线程都会进行阻塞等待,等该线程使用完成后进行解锁其他线程才能重新竞争锁。(悲观锁的一种实现是synchronized
)
synchronized
(该关键字在实例方法上,锁为当前实例this,关键字在静态方法上,锁为当前Class对象)同步方法,锁的是this对象;
synchronized是java的一个关键字,有两种使用方式:同步代码块和同步方法。
同步代码块:
使用synchronized之后,编译之后会在同步代码块前后加上monitorenter和monitorexit字节码指令,//他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。(每个对象实例都会有一个 monitor)
- 执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的monitor计数器+1。此时其他竞争锁的线程则会进入等待队列中。
- 执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。
同步方法:
他不是这种指令而是一个ACC_SYNCHRONIZED标志位,相当于一个flag,当JVM检测到这样一个flag,就会自动走一个同步方法调用的策略,持有monitor,然后执行方法,方法完成时释放monitor,在此期间,其他线程都无法获得这个monitor。
synchronized缺点:
- 如果临界区是只读操作,其实可以多线程一起执行,但是synchronized方法,同一时间只能有一个线程执行;
- synchronized无法知道线程有没有成功获取到锁
- 使用synchronized,如果临界区因为IO或者sleep方法等原因阻塞了,而当前线程又没有释放锁,就会导致所有线程等待。
锁的优化
(JDK1.6后)锁的状态从低到高依次为无锁->偏向锁->轻量级锁(CAS)->重量级锁,锁只能升级,不能降级。
偏向锁:当锁对象第一次被线程获取到后,JVM会将锁对象头中的状态标志位改为“01”,即偏向锁模式。然后使用CAS操作将当前线程的ID记录在锁对象的Mark Word中。持有偏向锁的这个线程以后每次进入该锁的同步块时,JVM都可以不再进行任何操作,连CAS操作都不需要。但是,一旦有第二条线程尝试去获取这个锁,那么偏向模式立即结束,进入轻量级锁的状态。
(PS:对象在内存中的存储布局方式可以分为3块区域:对象头、实例数据、对齐填充。对象头包括两部分信息Mark Word和Klass Point,JVM通过Klass Point指针来确定这个对象是哪个类的实例。)
轻量级锁:
在进入同步块的时候,如果同步对象没有被锁定(锁标志位为01状态时),虚拟机首先在当前线程栈中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象的Mark Word的拷贝,官方把这份拷贝叫做Displaced Mark Word(置换标记字),然后尝试使用CAS操作将锁对象的Mark Word轻量级锁指针指向当前Lock Record,将Lock Record的owner指针指向mark word,如果操作成功了,就持有锁并执行锁内代码,并且锁标志位变为“00”,转为轻量级锁。如果操作失败,则说明这个锁对象被其他线程抢占了,直接膨胀为重量级锁,锁标志状态变为“10”。
解锁的过程:
也是通过CAS操作进行的,如果对象的Mark Word仍然指着当前线程的锁记录,就用CAS操作吧对象当前的Mark Word和线程复制的Displaced Mark Word替换回来,如果替换成功则结束完成,如果替换失败,说明在持有锁这段时间里,有其他线程尝试获取过锁,那就要在释放锁的同时,将被挂起的线程唤醒。
ReentrantLock:(可重入锁)
(PS:如果一个线程已经获得了锁,其内部还可以多次申请该锁成功。那么我们就称该锁为可重入锁。)
Class A{
private final ReentrantLock lock = new ReentrantLock(); //Lock 底层是通过 CAS +AQS 来实现的
public void m(){
lock.lock();
try{
//保证线程安全的代码
}
finally{
lock.unlock();
}
}
}
从JVM层面上,synchronized是JVM的一个关键字,ReentrantLock其实是一个类,需要手动去编码。像synchronized在使用的时候是比较简单的,我直接同步代码块或者同步方法,也不需要去关心锁的释放;但ReentrantLock需要我们手动去Lock,然后配合try finally代码块一定要去把它的锁给释放。
区别+高级特性:
- 相比于synchronized,ReentrantLock需要显式手动地获取锁和释放锁 lock(), unlock()
- Lock只有代码块锁,synchronized有代码块锁和方法锁
- 等待可中断,如果有一个线程长期等待不到一个锁的时候,等待中的线程可以选择放弃等待,转而处理其他的任务,调用lockInterruptibly方法。
- 公平锁:synchronized和ReentrantLock默认都是非公平锁,但是ReentrantLock可以通过构造函数传参改变(有公平锁FairSync和非公平锁NonfairSync两个子类),只不过使用公平锁的话会导致性能急剧下降。(
公平锁
:先后调用lock方法的顺序依次获取锁) - 通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
- 提供condition可以指定去唤醒绑定到condition上的线程,来实现选择性通知。
- 配合try-finally代码块 用完一定要去把它的锁给释放。
- 使用Lock锁,JVM将花费较少的时间来调度线程,性能更好
lock方法是平常使用得最多的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待
tryLock方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true,如果获取失败(即锁已被其他线程获取),则返回false,也就说这个方法无论如何都会立即返回,在拿不到锁时不会一直在那等待。
lockInterruptibly方法比较特殊,当通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态。也就使说,当两个线程同时通过lock.lockInterruptibly()想获取某个锁时,假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt方法能够中断线程B的等待过程。
AQS:
全称是 AbstactQueuedSynchronizer 即抽象队列同步器。AQS是java并发包的基础类,ReentrantLock 实现加锁和锁释放就是通过AQS来实现的。
ReentrantLock加锁和释放锁的底层原理:
ReentrantLock内部包含了一个AQS对象,也就是AbstactQueuedSynchronizer类型的对象,这个AQS对象内部有一个核心的变量叫做state,是int类型的,代表了加锁的状态,初始状态下,这个state的值是0。AQS还有一个关键变量,用来记录当前加锁的是哪个线程,初始值是null。
调用Lock方法时,用CAS操作将state值从0变为1,设置成功后,将当前加锁线程设置成自己。因为它是可重入锁,所以它可以多次进行加锁,在加锁前会判断一下当前加锁线程是不是自己,如果是自己就可以重入加锁,每加一次锁,state值就累加1。如果线程1加锁之后,线程2跑过来想要加锁,它会先去看state的值,如果发现state!=0,就检查当前独占线程是不是自己,如果是则直接修改state+1并返回true,否则则返回false,加锁失败,将自己加入AQS的等待队列,等待当前占用线程释放锁,再去重新尝试加锁。
unLock方法,就是将AQS内的state变量的值-1,减为0的时候,则彻底释放锁,当前加锁线程也会变为null。