在日常开发中,多线程无数不在,尤其是android开发,看似业务代码没有调用多线程,实际上也在使用多线程,比如GC线程还有运行在子线程的网络请求。而在使用多线程的时候,不可避免的就需要做好并发安全,否则很容易出现死锁。为了优化多线程,首先就必须来了解一下关于多线程的一些基本概念。
1、线程和进程
- 线程是进程中可独立执行的最小单位,也是 CPU 资源分配的基本单位。
- 进程是程序向操作系统申请资源的基本条件,一个进程可以包含多个线程。
对手机而言,我们开发的app就是运行在手机系统里面的进程之一
2、线程属性
线程有四个属性:编号、名字、类别以及优先级
(1)编号:线程的编号用于标识不同的线程,每条线程拥有不同的编号。
(2)名字:每个线程都有自己的名字,名字的默认值是 Thread-线程编号,比如 Thread-0
这个名字可以在出问题的时候让我们方便德定位到具体出问题德线程。
(3)类别:线程的类别可分为守护线程和用户线程
(在线程启动前,通过 setDaemon(true) 即可把线程设置为守护线程。)
区别:当 JVM 要退出时,会先考虑是否所有的用户线程都已经执行完毕,而守护线程,则不会考虑。
(4)优先级:线程的优先级用于表示应用程序希望优先(并不一定会优先)运行的线程,线程调度器在运行线程时,会根据这个值来决定优先运行哪个线程
(它的默认值伟5,取值范围为 1~10)
3、线程使用
有了以上的线程的基本概念后,接下来看看线程的基本使用。
- 首先是简单的start()方法,启动线程。但是只能调用一次,再次调用会抛出非法线程状态异常。
new Thread(){
@Override
public void run() {
super.run();
}
}.start();
- join方法。
对于join方法,则用于插入到当前线程中执行,比如当前线程调用其他线程的join方法,则会插入其他线程,当前线程进入等待状态,直到插入的线程执行到结束后,当前线程继续执行。 - yield方法
此方法则是用于使当前线程放弃对处理器的占用,降低线程优先级,(注意,只是降低优先级,并不一定会使线程陷入暂停状态)
要使线程进入暂停状态,则需要使用sleep方法 - sleep(ms)方法
sleep方法接收long参数,用于使当前线程休眠ms毫秒
实现线程安全
要实现线程安全主要时确保线程的原子性、可见性和有序性。
- 原子性
原子性是指对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程看来是不可分割的,那么该操作就是原子操作,相应地称该操作具有原子性(Atomicity) - 可见性
可见性是指一个线程对共享变量的更新,对于其他读取该变量的线程是否可见。 - 有序性
有序性是指一个处理器在为一个线程执行的内存访问操作,对于另一个处理器上运行的线程来看是乱序的。
也就是说,要实现线程安全就要确保线程的着四个特性。
常见的方法则是使用锁和原子类型。
锁可分为内部锁、显式锁、读写锁、轻量级锁(volatile)四种。
1、内部锁
锁是为了解决多线程共享同一资源时,对资源的占用控制,防止线程之间同时修改同一资源信息,导致不可预知的问题。
在java中,通过 synchronized 关键字来实现内部锁机制,相应的方法称为同步方法,而相应的代码则称为同步代码块。
当多个线程一起访问同一个资源时,使用了内部锁,可以避免同时修改同一个资源导致问题。举一个简单的例子,如:
private static int resource=1;//同一个资源
private static void add(){
for (int i=1;i<10000;i++)
resource+=1;
System.out.println(resource);
}
首先是创建一个方法去访问一个共同的资源,然后在开启两个线程去访问他:
Thread thread1=new Thread(new Runnable() {
@Override
public void run() {
add();
}
});
Thread thread2=new Thread(new Runnable() {
@Override
public void run() {
add();
}
});
thread1.start();
thread2.start();
此时,最后thread2输出的结果并不是期望中的19999,而是14919或者是其他不确定的值,这是因为当两个线程分别读取resource为1,然后各自加1得到resource为2并先后写入,结果,虽然resource++执行了2次,但是实际resource的值只增加了1。
因此就必须给add方法加一个锁或者给共同资源resource加锁,如下:
private static synchronized void add(){
for (int i=1;i<10000;i++)
resource+=1;
System.out.println(resource);
}
这样一来,就可以得到预想中的19999。
2、显式锁
内部锁是我们常用的一种锁方式。
对于显示锁,它是 Lock 接口的实例,Lock 接口只是对显式锁进行了抽象,实际上它的实现类是ReentrantLock。
与内部锁不同的是,使用显式锁需要自己释放和获取锁,即调用lock() 与 unlock() 方法。
例如,同样是上面的例子,使用显示锁解决多线程同时访问资源的问题时,则是使用如下所示的方式:
private static void add(){
lock.lock();
for (int i=1;i<10000;i++)
resource+=1;
System.out.println(resource);
lock.unlock();
}
倒也简单,就只是加上加上lock和unlock方法即可。与内部锁相比,显得比较灵活便利。
3、读写锁
读写锁 ReadWriteLock 接口的实现类是 ReentrantReadWriteLock,与其他锁不同的是,读写锁具有读锁共享和写锁排他的特性
读锁共享指允许多个线程同时读取共享变量,即通过持有对应的读锁来访问共享变量,而读锁可以被多个线程持有。
写锁排他指在同一时刻,只允许一个线程更新共享变量,即通过持有对应的写锁访问共享变量,且写锁在任一时刻都只能被一个线程持有。
为了验证这两个特性,为方便比较,同样还是上面的例子,使用读写锁的解决方法如下:
private static ReadWriteLock readAdWriteLock = new ReentrantReadWriteLock();
private static Lock readLock = readAdWriteLock .readLock();
private static Lock writeLock = readAdWriteLock .writeLock();
private static int resource=1;
private static void add(){
writeLock.lock();//加锁
for (int i=1;i<10000;i++)
resource+=1;
System.out.println(resource);
writeLock.unlock();
}
在本例中,对资源自增1的操作是写操作,因此再加上writeLock.lock()和 writeLock.unlock()操作后即可实现对访问资源的保护,而对于读操作呢,由于读锁可以被共享,就不存在排他的特性:
private static void read(){
readLock.lock();
for (int i=1;i<100;i+=1)
System.out.println(Thread.currentThread().getName()+resource);
readLock.unlock();
}
截取部分输出结果如下:
可以看到,两个线程都可以持有读锁。
4、轻量级锁(volatile)
轻量级锁是指在满足一定的条件内,使用CAS(自旋)来尝试获取对象锁的一种机制,其中volatile关键字主要用于修饰共享变量,与其他锁相比,轻量级锁具有开销低的优点,主要用于修饰容易发生变化的变量。
死锁
讲到了多线程并发优化,就不得不一下死锁。
死锁的产生条件,举一个简单的例子,假如有线程A和线程B,线程A正在请求C资源,而线程B正在请求D资源,而这时,A线程占据着D资源同时B线程也占据着C资源,且A线程不肯释放D资源、B线程不肯释放C资源,于是两个线程就进入了循环等待的状态,产生了死锁。
大学里学过操作系统的都知道,产生死锁有着四个条件,即资源互斥、资源不可抢夺、占用并等待资源以及循环等待资源
其中,
资源互斥是指某种资源一次只允许一个进程访问,即该资源一旦分配给某个进程,其他进程就不能再访问,直到该进程访问结束。
资源不可抢夺是指资源只能被持有资源的线程主动释放,无法被其他线程释放。
占用并等待资源是指涉及的线程占用着资源(如例子中的A占用D)并等待其他资源(如C资源)
循环等待资源指涉及的线程必须等待其他线程释放资源,而其他资源又同时等待它释放资源。
解决死锁
知道了死锁的产生条件,要解决死锁也很简单,就是破坏死锁的四个条件即可。
即加锁顺序、加锁时限还有死锁检测。
加锁顺序即增加锁访问的顺序,以避免产生冲突;加锁时限则是设定锁循环等待的时限,如果超过时限就停止等待,而死锁判断则是及时判断死锁的出现。