文章目录
前言
随着互联网的快速发展,用户数量的不断提升,相对应系统的能力也要提升,其提升系统能力重要的手段之一就是并发编程,并发编程能够更充分的利用硬件(包括但不限于CPU)资源,从而提升系统的运行效率。
一、并发是什么?
提到并发,不得不先说一下操作系统,我们要知道,所有的应用程序都离不开操作系统,可以说是应用程序是面向操作系统编程,利用操作系统提供的接口去实现应用系统的能力。
1.1 操作系统
操作系统其实也是一个运行在硬件之上的程序,只不过比较特殊,最常见的操作系统有windows、unix,linux(其内核就是unix),这些操作系统底层实现其实是一些汇编指令即机器码(能够让硬件识别并执行的命令),这里我们不过多的讲述,有兴趣的同学可以自行查询相关资料。
1.2 进程
运行在操作系统上的所有的应用程序,都统称为“进程”,进程是操作系统分配资源的最小单元,比如操作系统会给进程分配CPU时间片,RAM内存等等。
了解了进程之后,那或许会有疑问,操作系统给应用程序分配了运行时所需要的资源后,那谁来使用,调度这些资源呢?或者说谁来真正的执行命令呢,答案是线程。
1.3 线程
线程是操作系统中执行命令的最小调度单元。如果不好理解的话,可以把硬件资源(内存,硬盘,CPU等等)想象为工厂,操作系统想象为厂长,进程想象为工厂里面的一个个车间,那么线程就是车间中真正干活的工人,那么这些工人听谁的派遣?当然是厂长了。
正常情况下,一个车间中不可能只有一个工人吧,当然只有一个工人也是可以的,只不过生产效率会慢,“工人”会更累,那么同样的情况,对应进程中,至少会存在一个线程。
在只有一个线程的情况下,虽然说是运行效率比较慢,但是不会出现资源抢夺的情况的,相应的就不会出现数据错乱的情况。
多线程并发执行
当我们要提升程序的运行速率时,其中一个重要的手段就是增加线程的数量,多个线程同时执行,速率肯定会有所提升,但是这里会容易有一个误区,并不是线程越多程序的执行效率会越快,因为线程的创建与销毁,线程的上下文切换,同样会消耗CPU,所以在多线程编程中,要创建适量的线程(比如使用线程池,后续会有文章详细介绍)。
但是在多个线程同时运行的情况下,就会出现资源抢夺的情况,相应的也会出现数据错乱的情况,比如就有可能出现A线程修改的数据被B线程覆盖(写覆盖),A线程读取了B线程修改后的数据(脏读)等等这些数据一致性的问题。
PS:在单个单核的CPU中,线程的并发执行并不是多个线程同时执行只是在宏观上可以看作是这样,其实在某一时刻,真正执行的线程只有一个,因为cpu的处理时间太快,执行完一个线程之后,立刻切换到下一个线程执行,所以在宏观上我们认为多个线程是“一起”执行的。
二、如何避免数据不一致的问题?
在高并发编程中,需要加锁来保证数据一致性,同时在Java中锁也是一个非常重要的概念,Java提供了种类丰富的锁,每种锁的特性也不用,适用的场景也不同。下面让我看来看看Java中锁的分类。
三、锁的种类
1.悲观锁/乐观锁
悲观锁:总是假设最坏的情况,假设每次拿到的数据都是别人更改后的,所以在每次读写数据的时候,都会加锁,这样别人要读写数据时,就会一直阻塞,知道它拿到锁。 Java中的synchronized关键字就是悲观锁,被synchronized修饰的方法、代码块,都是同步的,线程执行的时候,需要先获取相应的锁,执行完后释放锁,在持有锁的期间,其他线程获取锁时都会阻塞。
乐观锁: 总是很乐观,假设每次读写数据的时候别人都没有修改过,但是在写数据的时候,会判断一下在此期间有没有人更新过这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样会提高系统的吞吐量,在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS(Compare and Swap 比较并交换)实现的。
悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。
乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。
2.独享锁/共享锁
独享锁也称互斥锁,即同一时刻只能被一个线程所持有,对于Synchronized而言,是独享锁。
共享锁是指该锁可被多个线程所持有。
对于Java ReentrantLock而言,其是独享锁。但是对于ReadWriteLock接口,其读锁是共享锁,其写锁是独享锁。但是无论是ReentrantLock 还是 ReadWriteLock底层实现都是AQS(AbstractQueuedSynchronizer)。
3.可重入锁
可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。
对于Java ReetrantLock而言,从名字就可以看出是一个重入锁,对于Synchronized而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。
以下是Synchronized代码示例:
/*
进入非静态,同步方法A时,需要获取对象锁
*/
public synchronized void A() {
/*
进入非静态,同步方法B时,需要获取对象锁,
但是在进入A方法时,已经获取到了此对象锁,所以可以直接进入方法B
*/
B();
}
public synchronized void B() {
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
ReentrantLock代码示例:
public class Test {
private final ReentrantLock lock = new ReentrantLock();
public void A() {
try {
lock.lock();
System.out.println("进入到了方法A,线程名:" + Thread.currentThread().getName());
B();
} finally {
lock.unlock();
}
}
public void B() {
try {
lock.lock();
System.out.println("进入到了方法B,线程名:" + Thread.currentThread().getName());
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
new Test().A();
}
}
4.公平锁/非公平锁
公平锁是指多个线程按照申请锁的顺序来获取锁。
非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。
对于Java ReetrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
对于Synchronized而言,也是一种非公平锁,底层是JVM通过自旋的方式获取锁。
5.偏向锁/轻量级锁/重量级锁
这三种锁是指锁的状态,并且是针对Synchronized。在Java 5通过引入锁升级的机制来实现高效Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让他申请的线程进入阻塞,性能降低。
6.自旋锁
在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。一般可以使用CAS实现自旋锁。
四、总结
可见,在并发编程中,锁是多么重要的一个概念,我们可以通过控制加锁的粒度,提升系统的吞吐量,比如在只读时,可以不加锁,在写数据时,加锁。以及通过合理的控制代码加锁的粒度来提升系统吞吐量。