Java并发基础之多线程
文章也发布在我的个人博客上:https://blog.ysboke.cn/archives/129.html
概述
每个Thread类的示例都代表一个线程,而进程是操作系统级别的多任务,JVM就是运行在一个进程当中的。所以在Java中更多的应该考虑线程。进程的内存是可以被多个线程共享使用的。
使用线程根本上是为了更充分的利用cpu资源。
线程的状态
查看Java源码可知,线程的状态一共有6种,分别是新建、运行、阻塞、等待、超时等待、终止。即new、runnable、blocked、waiting、timed-waiting、terminated。
-
New:线程刚被创建时,未调用start方法,还未被纳入线程调度,此时为新建状态。
-
Runnable:Java中runnable与running统称runnable,此时调用了start方法(runnable),获取到cpu时间片后即可运行(running)。线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一一种方式。
-
blocked:阻塞状态,阻塞于锁。是线程阻塞在进入synchronized关键字修饰的方法或代码块(获取锁)时的状态。
-
waiting:此状态的线程需要其他线程的操作,例如通知或中断。处于这种状态的线程不会被分配CPU执行时间,它们要等待被显式地唤醒,否则会处于无限期等待的状态。
-
timed-waiting:相比于waiting,该状态可以自定义时间后自行返回。无须无限期等待被其他线程显示地唤醒,在达到一定时间后它们会自动唤醒。
-
terminated:表示线程已经执行完毕。当线程的run()方法完成时,或者主线程的main()方法完成时,我们就认为它终止了。这个线程对象也许是活的,但是,它已经不是一个单独执行的线程。线程一旦终止了,就不能复生。在一个终止的线程上调用start()方法,会抛出java.lang.IllegalThreadStateException异常。
创建线程
两种方法,一种是继承Thread类,但是如果类本身已经继承了其他类,那就得实现runnabl接口。
Thread本身是对Runnable接口的一个实现。
都要实现run方法,即线程逻辑。
什么是锁
简单来说,锁就是用来控制多线程情况下的访问行为,可以理解为一种许可,获得许可才允许执行。
数据在并发访问下容易出现读写不一致的问题,例如写线程还未结束写变量,读线程就来访问了,导致访问的数据不正确。所以给读写线程加速,未完成任务前不释放锁,此时其他线程就没法来读写变量,保证了原子性。
什么是重入锁
ReentrantLock是实现Lock接口的一个类,支持重入性。线程在被两次lock加锁后会被阻塞,在复杂的调用场景中为了避免这种情况,于是就有了可重入锁。只要lock和unlock次数相同即可。
重入锁和synchronized区别
性能上和synchronized几乎没区别,但是ReentrantLock功能更丰富,支持公平锁和非公平锁,更适合并发场景
使用上synchronized更简单,不用手动加锁解锁,都是隐式完成的。而ReentrantLock需要手动加锁解锁,解锁操作应该尽量放在finally代码块里。两种操作不平衡容易死锁。添加参数true后实现公平锁,不加为非公平锁。
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
重入锁的核心功能委托给内部类sync实现,并且根据是否是公平锁有FairSync和NonfairSync两种实现。这是一种典型的策略模式。
重入性实现原理
在AbstractQueuedSynchronizer对象里有个状态变量state,state为0表示锁空闲,大于0表示被占用,数值表示当前线程重复占用的次数。
private volatile int state;
那么实现一个简单的lock如下:
final void lock() {
// compareAndSetState就是对state进行CAS操作,如果修改成功就占用锁
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
//如果修改不成功,说明别的线程已经使用了这个锁,那么就可能需要等待
acquire(1);
}
其中的acquire():
public final void acquire(int arg) {
//tryAcquire() 再次尝试获取锁,
//如果发现锁就是当前线程占用的,则更新state,表示重复占用的次数,
//同时宣布获得所成功,这正是重入的关键所在
if (!tryAcquire(arg) &&
// 如果获取失败,那么就在这里入队等待
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
//如果在等待过程中 被中断了,那么重新把中断标志位设置上
selfInterrupt();
}
公平锁与非公平锁
默认情况下,重入锁是不公平的,多个线程竞争锁时不按照顺序来,而是随机获取。非公平锁如果第一次竞争失败,则会和公平锁一样进入等待队列。而公平锁则是按照先到先得的顺序获取锁,但是有性能损失。
也可以这么理解:公平锁是指当锁可用时,在锁上等待时间最长的线程将获得锁的使用权。而非公平锁则随机分配这种使用权。
从代码上看:
//非公平锁
final void lock() {
//上来不管三七二十一,直接抢了再说
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
//抢不到,就进队列慢慢等着
acquire(1);
}
//公平锁
final void lock() {
//直接进队列等着
acquire(1);
}
公平锁能避免饥饿争抢问题,线程不会重复获取锁。如果饥饿问题,那么可能有线程长时间获取不到锁。
就选择而言,大部分情况下使用非公平锁。
获取锁时限时等待
当使用synchronized实现锁时,阻塞在锁上的线程除非获得锁否则将一直等待下去,也就是说这种无限等待获取锁的行为无法被中断。而ReentrantLock给我们提供了获取锁限时等待的方法trylock(),可以传入时间参数,无参表示立即返回锁申请的结果。相比lock()来说,避免了无限等待的情况。
构造死锁场景:创建两个子线程,子线程在运行时会分别尝试获取两把锁。其中一个线程先获取锁1在获取锁2,另一个线程正好相反。如果没有外界中断,该程序将处于死锁状态永远无法停止。我们通过使其中一个线程中断,来结束线程间毫无意义的等待。被中断的线程将抛出异常,而另一个线程将能获取锁后正常结束。
public class ReentrantLockTest {
static Lock lock1 = new ReentrantLock();
static Lock lock2 = new ReentrantLock();
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new ThreadDemo(lock1, lock2));//该线程先获取锁1,再获取锁2
Thread thread1 = new Thread(new ThreadDemo(lock2, lock1));//该线程先获取锁2,再获取锁1
thread.start();
thread1.start();
}
static class ThreadDemo implements Runnable {
Lock firstLock;
Lock secondLock;
public ThreadDemo(Lock firstLock, Lock secondLock) {
this.firstLock = firstLock;
this.secondLock = secondLock;
}
@Override
public void run() {
try {
while(!lock1.tryLock()){
TimeUnit.MILLISECONDS.sleep(10);
}
while(!lock2.tryLock()){
lock1.unlock();
TimeUnit.MILLISECONDS.sleep(10);
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
firstLock.unlock();
secondLock.unlock();
System.out.println(Thread.currentThread().getName()+"正常结束!");
}
}
}
}
线程通过调用tryLock()方法获取锁,第一次获取锁失败时会休眠10毫秒,然后重新获取,直到获取成功。第二次获取失败时,首先会释放第一把锁,再休眠10毫秒,然后重试直到成功为止。线程获取第二把锁失败时将会释放第一把锁,这是解决死锁问题的关键,避免了两个线程分别持有一把锁然后相互请求另一把锁。
使用condition
condition是重入锁的伴生对象。它提供了在重入锁的基础上,进行等待和通知的机制。可以使用 newCondition()方法生成一个Condition对象:
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
使用synchronized结合Object上的wait和notify方法可以实现线程间的等待通知机制,而ReentrantLock结合Condition接口同样可以实现这个功能。而且相比前者使用起来更清晰也更简单。
如何使用condition?
Condition接口在使用前必须先调用ReentrantLock的lock()方法获得锁。之后调用Condition接口的await()将释放锁,并且在该Condition上等待,直到有其他线程调用Condition的signal()方法唤醒线程。使用方式和wait,notify类似。
public class ConditionTest {
static ReentrantLock lock = new ReentrantLock();
static Condition condition = lock.newCondition();
public static void main(String[] args) throws InterruptedException {
lock.lock();
new Thread(new SignalThread()).start();
System.out.println("主线程等待通知");
try {
condition.await();
} finally {
lock.unlock();
}
System.out.println("主线程恢复运行");
}
static class SignalThread implements Runnable {
@Override
public void run() {
lock.lock();
try {
condition.signal();
System.out.println("子线程通知");
} finally {
lock.unlock();
}
}
}
}
运行结果:
主线程等待通知
子线程通知
主线程恢复运行
实现一个阻塞队列
使用condition实现,
阻塞队列是一种特殊的先进先出队列,它有以下几个特点:
1、入队和出队线程安全
2、当队列满时,入队线程会被阻塞;当队列为空时,出队线程会被阻塞。
阻塞队列:
public class MyBlockingQueue<E> {
int size;//阻塞队列最大容量
ReentrantLock lock = new ReentrantLock();
LinkedList<E> list=new LinkedList<>();//队列底层实现
Condition notFull = lock.newCondition();//队列满时的等待条件
Condition notEmpty = lock.newCondition();//队列空时的等待条件
public MyBlockingQueue(int size) {
this.size = size;
}
public void enqueue(E e) throws InterruptedException {
lock.lock();
try {
while (list.size() ==size)//队列已满,在notFull条件上等待
notFull.await();
list.add(e);//入队:加入链表末尾
System.out.println("入队:" +e);
notEmpty.signal(); //通知在notEmpty条件上等待的线程
} finally {
lock.unlock();
}
}
public E dequeue() throws InterruptedException {
E e;
lock.lock();
try {
while (list.size() == 0)//队列为空,在notEmpty条件上等待
notEmpty.await();
e = list.removeFirst();//出队:移除链表首元素
System.out.println("出队:"+e);
notFull.signal();//通知在notFull条件上等待的线程
return e;
} finally {
lock.unlock();
}
}
}
测试代码:
public static void main(String[] args) throws InterruptedException {
MyBlockingQueue<Integer> queue = new MyBlockingQueue<>(2);
for (int i = 0; i < 10; i++) {
int data = i;
new Thread(new Runnable() {
@Override
public void run() {
try {
queue.enqueue(data);
} catch (InterruptedException e) {
}
}
}).start();
}
for(int i=0;i<10;i++){
new Thread(new Runnable() {
@Override
public void run() {
try {
Integer data = queue.dequeue();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
为了让大家更好的理解重入锁的使用方法。现在我们使用重入锁,实现一个简单的计数器。这个计数器可以保证在多线程环境中,统计数据的精确性,请看下面示例代码:
public class Counter {
//重入锁
private final Lock lock = new ReentrantLock();
private int count;
public void incr() {
// 访问count时,需要加锁
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
//读取数据也需要加锁,才能保证数据的可见性
lock.lock();
try {
return count;
}finally {
lock.unlock();
}
}
}
总结
ReentrantLock是可重入的独占锁。比起synchronized功能更加丰富,支持公平锁实现,支持中断响应以及限时等待等等。可以配合一个或多个Condition条件方便的实现等待通知机制。
- 对于同一个线程,重入锁允许你反复获得通一把锁,但是,申请和释放锁的次数必须一致。
- 默认情况下,重入锁是非公平的,公平的重入锁性能差于非公平锁
- 重入锁的内部实现是基于CAS操作的。
- 重入锁的伴生对象Condition提供了await()和singal()的功能,可以用于线程间消息通信。
编写参考:
https://www.cnblogs.com/takumicx/p/9338983.html