并发基础知识之线程的基本概念
1.创建线程
线程表示一条单独的执行流,它有自己的程序执行计数器,有自己的栈。
创建线程有两种方式:一种是继承Thread,另外一种是实现Runnable接口。
(1)继承Thread
Java中java.lang.Thread这个类表示线程,一个类可以继承Thread并重写run方法来实现一个线程。
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("hello");
}
}
线程需要启动的话,得执行start方法。
public static void main(String[] args) {
Thread thread = new MyThread();
thread.start();
}
如果不执行start方法,那么run方法就只是一个普通的方法。
每个线程都有一个id和name,要获取它们,可以使用getId()和getName()方法,要获取当前执行的线程,可以使用currentThread()方法。
修改上面的run方法
@Override
public void run() {
System.out.println("thread name:" + Thread.currentThread().getName());
System.out.println("hello");
}
如果在main方法中通过start方法启动线程,则输出
thread name:Thread-0
hello
如果直接调用run方法,则输出
thread name:main
hello
调用start方法后,就有两条执行流,一条执行run方法,一条执行main方法,两条执行流并发执行,操作系统负责调度。
(2)实现Runnable接口
通过继承Thread来实现线程比较简单,但java只支持单继承,这时就可以通过实现Runnable接口来实现线程,Runnable接口只有一个run方法。
public class MyThread implements Runnable {
@Override
public void run() {
System.out.println("hello");
}
}
启动线程同样要使用start方法,不过创建一个线程的方式有所不同
public static void main(String[] args) {
Thread thread = new Thread(new MyThread());
thread.start();
}
2.线程的基本属性和方法
(1)id和name
前面我们已经说过了,每个线程都有一个id和name,id是一个递增的整数,每创建一个线程就加1.name的默认值是Thread-后面跟一个编号,name可以在Thread的构造方法中指定。也可以通过setName方法进行设置,给Thread设置一个友好的名字,可以方便测试。
(2)优先级
线程有一个优先级的概念,优先级从1到10,默认是5,可以通过setPriority方法设置。不同的操作系统略有不同。
public final void setPriority(int newPriority)
public final int getPriority()
(3)状态
线程有一个状态的概念,Thread有一个getState方法可以获取线程的状态。
public State getState()
返回值为Thread.State,它是一个枚举类型,有如下值
public enum State {
NEW,
RUNNABLE,
BLOCKED,
WAITING,
TIMED_WAITING,
TERMINATED;
}
NEW:没有调用的线程状态。
RUNNABLE:调用start后线程在执行run方法且没有阻塞。
TERMINATED:线程运行结束后状态为TERMINATED。
BLOCKED,WAITING,TIMED_WAITING:都表示线程被阻塞了,在等等一些条件。
Thread还有一个方法,表示线程是否还活着
public final native boolean isAlive()
线程被启动后,run方法运行结束前,此方法返回值都是true。
(4)是否是daemon线程
Thread有一个是否daemon线程的属性,相关方法:
public final void setDaemon(boolean on)
public final boolean isDaemon()
前面我们说了,启动线程会启动一条单独的执行流,整个程序只有在所有线程都执行完毕后才会退出,但daemon线程除外,当整个程序剩下的都是daemon线程时,程序就会退出。
daemon线程其实就是后台进程,比如垃圾收集器。作用就是辅助其他的线程,当其他的线程执行完毕后,它们也就没有存在的必要了。
(5)sleep方法
调用该方法可以使当前线程睡眠,但不释放对象锁。(wait方法会释放对象锁)
public static void **sleep**(long millis) throws InterruptedException
(6)yield方法
Thread还有一个让出CPU的方法
public static native void yield()
调用该方法,是告诉系统的调度器:我现在不着急使用CPU,你可以让其他线程先运行。
(7)join方法
调用该方法,可以让其他线程等待调用这个方法的线程结束。
public final void join() throws InterruptedException
比如前面的main线程和thread线程,如果希望main线程在thread线程结束后再退出,则加上
thread.join()
3.共享内存及可能存在的问题
(1)共享内存
public class ShareMemoryDemo {
private static int shared = 0;
private static void incrShared() {
shared++;
}
static class ChildThread extends Thread {
List<String> list;
public ChildThread(List<String> list) {
this.list = list;
}
@Override
public void run() {
incrShared();
list.add(Thread.currentThread().getName());
}
}
public static void main(String[] args) throws InterruptedException {
List<String> list = new ArrayList<String>();
Thread t1 = new ChildThread(list);
Thread t2 = new ChildThread(list);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(shared);
System.out.println(list);
}
}
大部分情况下,会输出如下
2
[Thread-0, Thread-1]
通过这个例子,说明执行流、内存和代码的关系如下:
- 该代码中有三条执行流,一条执行main方法,另外两条执行ChildThread 的run方法。
- 不同执行流可以访问和操作相同的变量,如本例中的shared和list变量。
- 不同的执行流可以执行相同的代码。
- 当多条执行流执行相同的d代码时,每条执行流都有单独的栈,方法中的参数和局部变量都有自己的一份。
(2)竞态条件
竞态条件是指当多个线程访问和操作同一变量的时候,执行的结果和执行次序有关,结果可能正确也可能不正确。
public class CounterThread extends Thread {
private static int counter = 0;
@Override
public void run() {
for(int i = 0; i < 1000; i++) {
counter++;
}
}
public static void main(String[] args) throws InterruptedException {
int num = 1000;
Thread[] threads = new Thread[num];
for(int i = 0; i < num; i++) {
threads[i] = new CounterThread();
threads[i].start();
}
for(int i = 0; i < num; i++) {
threads[i].join();
}
System.out.println(counter);
}
}
期望的结果是1000*1000,但运行输出一般不是这个数。
因为counter++不是一个原子操作,主要分成以下三步
- 取得counter的值
- counter的值加1
- 将新值重新赋给counter
这样的情况下,如果我们两个线程同时取得了counter的值,比如100,那么第一个线程执行完后counter的值为101,第二个线程执行完后,counter的值还是101,这肯定会与我们的期望值不一样。
解决这个问题的方法有:
- 使用synchronized关键字
- 使用显式锁
- 使用原子变量
这个在后面的文章中我会一一讲。
(3)内存可见性
所谓内存可见性,就是一个线程对变量的修改,其他的线程能够马上看到。因为多个线程可以共享和操作相同的变量,所以如果一个线程对变量的修改,其他的线程不能马上看到,甚至永远也看不到,这就会出错了。
public class VisibilityDemo {
private static boolean shutdown = false;
static class HelloThread extends Thread {
@Override
public void run() {
while(!shutdown) {
System.out.println("1");
}
System.out.println("exit hello");
}
}
public static void main(String[] args) throws InterruptedException {
new HelloThread().start();
Thread.sleep(1000);
shutdown = true;
System.out.println("exit main");
}
}
上面的程序中,一开始shutdown变量的值为false,这样启动线程后就会一直打印1,在main方法中,启动线程后,我们休息一下,然后把shutdown的值设为true,期望应是停止打印1,但结果可能是一直循环下去。
这就是内存可见性问题,在计算机系统中,除了内存,数据还会被缓存到cup的寄存器以及各级缓存中,当访问一个变量的时候,可能是直接从寄存器或缓存中读取,当修改变量的时候,也可能是先写到缓存中,之后才会同步更新到内存中,这是延迟写。在单线程中,这一般没问题,但在多线程中,就可能出现很严重的问题,一个线程对另一个变量的修改,另一个线程看不懂,一是没有及时同步到内存,二是另一个线程根本没从内存中读。
解决这个问题,有以下方法:
- 使用volatile关键字
- 使用synchronized关键字或显式锁同步。
4.线程的优缺点
优点:
- 能充分利用多cpu的能力。
- 充分利用硬件资源,cpu和硬盘。
- 简化模型及IO处理
缺点:
线程的调度和切换都需要花费时间,当有大量可运行线程的时候,操作系统会忙于调度,为一个线程分配一段时间,执行完后,再让另一个线程执行,一个线程被切换出去,操作系统需要保存它的当前上下文状态到内存,上下文状态包括当前cpu寄存器的值,程序计数器的值等,当一个线程被切换回来后,操作系统又要恢复它的上下文,整个过程就是上下文切换。这个切换不仅耗时,而且使cpu中的好多缓存失效。
往期文章