Java基础知识梳理--线程
来源:互联网 发布:知豆电动汽车怎么样 编辑:程序博客网 时间:2024/05/24 11:15
多线程
1、线程处理概述
1.1 进程和线程
- 进程是操作系统正在执行的不同应用程序的一个实例,线程是操作系统分配处理器时间的基本单元.
- 每个进程运行在自己的地址空间,而线程共享数据内存和IO这些资源,这使得线程之间的通信比进程之间更加高效,同时也增加了线程之间协调的难度
1.2 线程的优缺点
2、创建线程
2.1 使用Thread直接创建线程
Java中创建线程有两种方式:继承java.lang.Thread类和实现java.lang.Runnable接口.
2.1.1 继承Thread类创建线程
当调用Thread类对象实例的start()方法时,将自动调用对象的run()方法,即运行线程. 创建步骤如下,
private class MyThread extends Thread { @Override public void run() { // ... }}// 开启线程MyThread myThread = new MyThread(); myThread.start();
2.1.2 实现Runnable接口创建线程
private static Runnable runnable = new Runnable() { public void run() { // ... }};// 开启线程new Thread(runnable).start();
2.1.3 通过匿名类创建线程
new Thread(new Runnable() { public void run() { // ... }}).start();
2.1.4 说明
- 如果使用
Thread thread = new Thread()
的方式创建线程,不会报错。但是系统不会为为该对象分配资源,这种线程只能启动或终止。 - Java中的线程是抢占式的。
- 在使用Runnable的实例作为参数创建新线程的时候要注意:在创建多个线程的情况下,如果传入的Runnable实例是同一个实例的话,那么这几个线程是共享这个实例的数据的,而如果不是同一个实例,则每个线程有一份自己的数据。当共享实例的时候,有时需要引入同步机制。
- 传入不同Runnable实例就像是多个进程各自拥有自己的地址空间,而多个线程分享同一个Rnnable时它们共同使用这一块地址空间。
2.2 使用Executor创建线程
Executors类有许多静态工程方法可以用来构建线程池。使用线程池的好处在于:
因为线程的创建和销毁会占有一定的资源开销,尤其是当线程需要执行的逻辑耗时比较短,而创建和销毁的时间占用比较长的时候,对每个任务都创建和销毁线程就不太划算了。而线程池为我们提供了一种复用线程的机制,我们可以只创建执行数量的线程,然后将任务不断地提交到线程池中执行。
2.2.1 newCachedThreadPool
对每个任务,如果有空闲线程,立即让它执行任务,若无,则创建新线程;
ExecutorService executor = Executors.newCachedThreadPool(); for (int i=0;i<5;i++) { executor.execute(new MyRunnable());}executor.shutdown();
输出结果:
#0: 9 #0: 8 #2: 9 #2: 8 #2: 7 #2: 6 #2: 5 #2: 4 #2: 3 #2: 2 #2: 1 #2: terminated #0: 7 #0: 6 #0: 5 #0: 4 #0: 3 #0: 2 #0: 1 #0: terminated#1: 9 #1: 8 #1: 7 #1: 6 #1: 5 #1: 4 #1: 3 #1: 2 #1: 1 #1: terminated #4: 9 #4: 8 #4: 7 #4: 6 #3: 9 #4: 5 #3: 8 #4: 4 #3: 7 #4: 3 #3: 6 #4:2 #3: 5 #4: 1 #3: 4 #4: terminated #3: 3 #3: 2 #3: 1 #3: terminated
2.2.2 newFixedThreadPool
构建一个具有固定大小的线程池,若提交的任务数目大于空闲线程,得不到服务的任务放在队列中,执行完其他任务再执行这些任务
ExecutorService executor = Executors.newFixedThreadPool(5); for (int i=0;i<5;i++) { executor.execute(new MyRunnable());}executor.shutdown();
输出结果:
#0: 9 #0: 8 #0: 7 #0: 6 #0: 5 #0: 4 #0: 3 #0: 2 #0: 1 #0: terminated#2: 9 #2: 8 #2: 7 #2: 6 #2: 5 #2: 4 #2: 3 #3: 9 #3: 8 #3: 7 #3: 6 #3: 5 #3: 4 #3: 3 #3: 2 #3: 1 #3: terminated #2: 2 #2: 1 #2: terminated#4: 9 #4: 8 #4: 7 #4: 6 #4: 5 #4: 4 #4: 3 #4: 2 #4: 1 #4: terminated #1: 9 #1: 8 #1: 7 #1: 6 #1: 5 #1: 4 #1: 3 #1: 2 #1: 1 #1: terminated
2.2.3 newSingleThreadPool
大小为1的线程池,提交的任务会按照提交的顺序依次地被执行(执行完毕一个,才去执行另一个)。
ExecutorService executor = Executors.newSingleThreadExecutor();for (int i=0;i<5;i++) { executor.execute(new MyRunnable());}executor.shutdown();
输出结果:
#0: 9 #0: 8 #0: 7 #0: 6 #0: 5 #0: 4 #0: 3 #0: 2 #0: 1 #0: terminated#1: 9 #1: 8 #1: 7 #1: 6 #1: 5 #1: 4 #1: 3 #1: 2 #1: 1 #1: terminated#2: 9 #2: 8 #2: 7 #2: 6 #2: 5 #2: 4 #2: 3 #2: 2 #2: 1 #2: terminated#3: 9 #3: 8 #3: 7 #3: 6 #3: 5 #3: 4 #3: 3 #3: 2 #3: 1 #3: terminated #4: 9 #4: 8 #4: 7 #4: 6 #4: 5 #4: 4 #4: 3 #4: 2 #4: 1 #4: terminated
可以看出上面执行的顺序是按照传入的Runnable的顺序依次地执行
2.3 从任务中返回结果
与Runnable类似的还有Callable,
public interface Callable<T> { T call() throws Exception;}
这里我们通过泛型参数V指定返回的结果类型。在创建完毕了指定的Callable<T>
之后,我们可以使用ExecutorService
的方法
<T> Future<T> submit(Callable<T> task)
来实现将指定的任务提交给线程池。注意这里的返回类型是Future<T>
。在我们每次提交了任务之后就会获得一个Future<T>
对象,我们可以使用这个对象来获取任务的执行结果,但是这个对象是阻塞的。也就是说,虽然我们可以调用get()方法来尝试获取结果,但是如果计算没有完成,就会在get()方法上阻塞,直到任务最终执行完毕才会把执行的结果返回给我们。
ExecutorService executor = Executors.newFixedThreadPool(5);List<Future<Integer>> results = new ArrayList<Future<Integer>>();for (int i=0;i<5;i++) { results.add(executor.submit(new CallableTask(i, i)));}for (Future<Integer> result : results) { try { System.out.println(result.get()); } catch (InterruptedException e) { e.printStackTrace(); } catch (ExecutionException e) { e.printStackTrace(); }}
这个程序的结果很简单,我们在这里想说明的一点是,在我们提交了任务之后,当在另一个for循环中获取的结果时会阻塞1秒的时间。当计算完毕之后解除了阻塞之后就会立即输出所有计算结果。
当然也可以使用isDone()方法来判断任务是否完成,并决定是否要调用get()方法。
3、线程状态和生命周期
3.1 线程的状态
3.2 线程的启动start()、停止stop()、挂起suspend()和唤醒resume()
通过对象的start()
, stop()
, suspend()
, resume()
方法可以分别用来启动/停止/挂起/继续线程,但是后面三种方法都已经过时,调用可能发生不可预料的结果——如果线程被停止或者挂起的时候,它仍然占有共享的资源,那么有可能会导致线程死锁。
说明:调用start方法,线程处于runnable,线程可运行,但是无法确定线程是否正在运行,这取决于操作系统提供的运行时间。
run()方法执行结束之后,线程自动终止;如果run()无限循环,可以考虑加加标识,在一定情况下退出. 不推荐使用stop()方法,另外,如果线程终止了,将无法再次启动.
说明:
- 一个线程会结束的原因可能是下面两者之一:
- run方法正常退出而线程自然地死亡;
- 一个没有被捕获的异常终止了run方法而意外地死亡。
3.3 线程休眠Thread.sleep()
静态方法Thread.sleep(long millis)
和Thread.sleep(long millis, int nanos)
强行将当前线程休眠(暂停执行)指定时间,睡眠结束,即返回可运行状态.
也可以使用TimeUnit.MILLISECONDS.sleep(1000);
方法来实现线程的休眠。
注意:
- 一个线程不能针对另一个线程调用Thread.sleep(),即一个线程只能让自己睡眠,故除了主线程mian()外,Thread.sleep()代码应该置于run()方法内.
- sleep()方法会抛出一个InterruptedException异常。
3.4 线程优先级setPriority()
优先级使用正整数试着,通常为0~10,默认为5. Thread类中也定义了3个静态最终常量:
- Thread.MIN_PRIORITY(1)
- Thread.NORM_PRIORITY(5)
- Thread.MAX_PRIORITY(10).
线程是根据优先级调度执行的,尽管CPU处理现有的线程集的顺序是不确定的,但是调度器倾向于让优先权最高的线程先执行。这不意味着优先权低的程序得不到执行,只是执行的频率较低。
说明:
- 默认情况下,一个线程继承它父线程的优先级,可以使用setPriority()方法提高或降低一个线程的优先级;
- 高优先级线程没有进入非活动状态,低优先级线程永远不可能执行。每当调用一个新线程时,首先会在具有高优先级的线程中选择。尽管这样可能会使低优先级线程完全饿死;
- 在绝大多数的时间里,线程都应该以默认的优先级运行,视图操纵线程优先级通常是一种错误。
3.5 让步Thread.yield()
即暂停当前正在执行的线程对象,并执行其他线程. 并非永久暂停,只是让步一次执行时间片. 通过Thread.yield()
实现。yield()是Thread的一个静态方法,它给线程调度机制一个暗示:当前线程(在run方法中调用yield()方法的线程)的工作已经差不多了,可以让别的线程使用CPU了。
但是,大体上,对任何重要的控制或在调整应用时,都不能依赖于yield().
3.6 加入一个线程jon()
join()是Thread的实例方法,它用来等待,直到指定线程结束。如果我们在线程A中调用了B的join()方法,就表示我们将A添加到了B的尾部,如果B不执行完A不继续执行. join()重载版本
- void join() //加入线程,等待该线程终止后运行
- void join(long millis) //加入线程,等待该线程millis后运行,0为无限等待
- void join(long millis, int nanos) //加入线程,等待该线程millis+nanos后运行
线程的join()方法允许传入long型的时间,表示我们可以为线程设置等待的时间上限。
下面是线程的一份演示代码,这里我们在一个线程被加入了另一个线程之后,故意调用了interrupt()方法,以演示join()中出现异常的情况:
public static void main(String ...args) { Thread sleeper = new Sleeper(); Thread iJoiner = new Joiner(sleeper); Thread joiner = new Joiner(sleeper); joiner.start(); sleeper.start(); iJoiner.start(); iJoiner.interrupt();}private static class Sleeper extends Thread { @Override public void run() { try { System.out.println("Sleeper: before sleep."); Thread.sleep(2000); System.out.println("Sleeper: after sleep."); } catch (InterruptedException e) { e.printStackTrace(); } }}private static class Joiner extends Thread { private Thread sleeper; public Joiner(Thread sleeper) { this.sleeper = sleeper; } @Override public void run() { try { System.out.println("Joiner: before join."); sleeper.join(); System.out.println("Joiner: after join."); } catch (InterruptedException e) { System.out.println(e); } }}
程序的输出结果:
Joiner: before join.Sleeper: before sleep.Joiner: before join.java.lang.InterruptedExceptionSleeper: after sleep.Joiner: after join.
注意,因为线程的join()方法的本意是等待另一份线程直到结束,所以,如果我们没有对指定的线程调用start()方法,那么是没有join()的效果的(因为线程本来就没启动,所以也不用等待了)。
以上程序运行的效果是:sleeper, iJoiner和joiner先执行前面的一句。这时候iJoiner和joiner将sleeper加入进来,它们需要等待线程sleeper执行完毕,并结束之后才会继续进行。而iJoiner因为在没有结束的时候就调用了interrupt()方法,所以它会抛出一个异常,并在中途结束整个方法。
3.7 线程中断interrupt()
线程中断interrupt()不是立即终止线程,而是向处于阻塞状态的线程抛出一个中断异常InterruptedException。处于阻塞状态(是指处于sleep方法或者wait方法中)的线程可以捕获该异常,从而进行相应的处理,以提早终止被阻塞的状态.
- 对线程调用interrupt方法时,线程的中断状态将被置位,这是每个线程都具有的boolean状态。每个线程都应该检查这个状态以判断线程是否被中断。
- 通常可以使用interrupt作为线程结束的标识。
后台线程
Java线程分为两类:用户线程和Daemon线程.
- 用户线程是通常意义的线程,Java应用程序运行时,通过main()方法进入. 在主线程中可以创建和启动新线程,默认为用户线程. 只有所有用户线程结束后,应用程序才终止.
- 通过setDaemon()方法,可以设置线程为Daemon()线程,在Daemon线程中创建的线程默认为Daemon线程. 通过方法isDaemon()可以判断一个线程是否为Daemon线程.
- Daemon线程(守护线程)是一个服务线程,其优先级最低,一般为其他线程提供服务. 通常Daemon线程体是一个无限循环,如果所有的非Daemon线程都结束了,则Daemon线程自动终止.
- Daemon线程应该永远不访问固有资源,如文件、数据等,因为它会在任何时候,甚至任一个操作中间发生中断。
线程组
最好把线程组看作一次不好的尝试,忽略就好。
4、线程同步
4.1 Sychronized
要控制线程对共享资源的访问,一种方式是将需要共享的资源包装进一个对象。然后把要访问该对象的资源的方法标记为sychronized。如果某个线程处于对标记为sychronized的方法调用中,那么在这个方法返回之前,其他调用类中任何标记sychronized方法的线程都会被阻塞。
注意在使用并发时,将域设置为private是非常重要的,否则,sychronized关键字则不能防止其他任何直接访问域,这样会产生冲突。
还要注意如果我们对一个类中的两个方法f()和g()加了同步锁,那么如果我们的一个线程获取了f()的锁,并且还没有释放,那么任何其他线程都不能访问g()方法,但是可以访问h()方法:
public synchronized void f() {}public synchronized void g() {}public void h() {}
JVM会跟踪重复加锁并计数,每当离开一个sychronized方法时,计数递减,当计数减至0的时候,锁被完全释放,此时别的任务就可以使用此资源。
4.2 显式的Lock对象
也可以使用Lock对象来进行加锁,Lock对象必须被显式地创建、锁定和释放。它的缺点是,相比于使用sychronized关键字加锁的方式,它不够灵活,而且容易出错。因此,通常只有在解决特殊问题时,才使用显式的Lock对象。
使用Lock的方式,如果事务失败了,那么那么我们还可以对异常进行处理。而使用sychronized的时候,只能抛出异常,而无法做任何清理工.
我们需要使用try-finally结构来使用Lock加锁,而且如果有return语句的话,它return应该被放置在try语句块中,以确保unlock不会过早地发生,从而将数据暴露给第二个任务。
除了使用lock()方法,还可以使用tryLock()方法,它有两个重载的方法,其中的一个还可以用来设置超时的时间。它具有一个boolean类型的返回值,用来判断是否成功获取到锁。
ReentranLock lock = new ReentrantLock();public int method() { lock.lock(); try{ // ... return ret; } finally { lock.unlock(); }}
4.3 原子性与可见性
原子性可以应用于除了long和double之外的所有基本类型上的简单操作。对于long和double类型,它们的读写被当作两个分离的32位操作来执行,这就有可能导致行为的不确定性。但是,如果定义long和double类型时,使用了volatile关键字,就会获得原子性。
++和–操作虽然看起来简洁,实际上该操作不是原子的。
可见性:一个任务做出的修改,即使在不中断的意义上讲是原子的,但是其他任务也可能是不可见的。voliate关键字保证了应用程序中的可见性。若将域声明为volatile的,那么只要对这个域产生了写操作那么所有的读操作都可以看到这个修改,即使使用了本地缓存。
如果多个任务在同时访问某个域,这个域就应该是volatile的,否则,这个域应该只能由同步来访问。同步也会导致向主存中刷新,因此,如果一个域完全由sychronized方法或语句来防护,那就不必将其设置为volatile的,
入股一个域可以被多个任务同时访问,且这些任务中至少有一个是写入操作,就应该将这个域设置为volatile的。
示例代码 该程序在找到一个奇数的时候结束程序的运行:
public static void main(String ...args) { ExecutorService executor = Executors.newCachedThreadPool(); final MyClass myClass = new MyClass(); Runnable runnable = new Runnable() { public void run() { while (true) { myClass.add(); } } }; executor.execute(runnable); executor.shutdown(); int val; while (true) { if ((val = myClass.getValue()) % 2 != 0) { System.out.println(val); System.exit(1); } }}private static class MyClass { private int value = 0; public synchronized void add() { value++; value++; } public int getValue() { return value; }}
实际程序的执行结果是,输出了9719并结束了程序。按理说,add()方法中的value连续自增两次是没有奇数的,那么为什么会出现奇数呢?
出现奇数是程序中缺少同步使得其数值可以在处于不稳定的中间状态的时候被读取。解决的方法是在getValue方法上加上sychronized关键字,这样只有当一个线程释放了锁之后,另一个线程才能获取到写入的值。
4.4 原子类
解决上面的问题还可以使用原子类:这里使用了AtomicInteger类,并用了它的addAndGet(2)方法。这是一个原子的操作,用来取代之前的两次自增操作。而且使用了原子类之后我们就不需要在方法上面添加sychronized关键字了,因为它的每次操作都是原子的。
public static void main(String ...args) { ExecutorService executor = Executors.newCachedThreadPool(); final MyClass myClass = new MyClass(); Runnable runnable = new Runnable() { public void run() { while (true) { myClass.add(); } } }; executor.execute(runnable); executor.shutdown(); int val; while (true) { if ((val = myClass.getValue()) % 2 != 0) { System.out.println(val); System.exit(1); } }}private static class MyClass { private AtomicInteger value = new AtomicInteger(0); public void add() { value.addAndGet(2); } public int getValue() { return value.get(); }}
Atomic类被设计用来构建concurrent包中的类,因此只有在特殊情况下才在自己的代码中使用它们。对于常规编程,它们很少派上用场,但是在涉及性能调优时,它们大有用武之地。
4.5 临界区
临界区是指通过使用sychronized关键字分离出来的代码段。这里sychronized常被用来指定某个对象,此对象的锁被用来对花括号内的代码进行同步控制:
sychronized(syncObject) { // ...}
这也被成为同步代码块。在进入此段代码之前,必须得到syncObject(是syncObject上面的锁)上面的锁。如果其他线程已经得到了这个锁,那么就得等到锁被释放以后,才能进入临界区。
通过使用同步代码块,而不是对整个方法进行同步控制,可以使多个任务访问对象的时间性能得到显著提高。
使用sychronized同步代码块最合理的方式是,使用其方法正在被调用的当前对象:sychronized(this). 在这种方式中,如果获得了sychronized块上的锁,那么该对象其他的sychronized方法和临界区就不能被调用。因此,如果在this上面同步,临界区的效果就会直接缩小在同步的范围内。
private Object syncObject = new Object();public synchronized void f() { // ...}public void g() { synchronized (syncObject) { // ... }}
如上所示,这里第一个锁加在f方法上,它会对f()方法所在的整个类进行加锁。而g()方法内部的同步代码块将锁加在syncObject对象上,它不会对g()方法所在的整个类进行加锁,仅对syncObject加锁。所以,如果我们在不同的线程中同时访问f()和g()两个方法是不会有问题的。但是,有一点格外需要注意的是,f()方法内部不应该用到syncObject对象,因此有可能发生死锁。
4.6 线程本地存储
防止任务在共享资源上产生冲突的第二种方式是根除对变量的共享,使用线程的本地存储为使用相同变量的不同线程创建不同的存储。
下面是一个ThreadLocal的实例。这里我们使用了静态的全局变量threadLocal来保存Integer类型的值,包括在main线程,我们在不同的线程中将指定的数字传入到threadLocal中进行保存。然后,再将其读取出来:
private static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>();public static void main(String...args) { threadLocal.set(-1); ExecutorService executor = Executors.newCachedThreadPool(); for (int i=0;i<5;i++) { final int ii = i; executor.submit(new Runnable() { public void run() { threadLocal.set(ii); System.out.println(threadLocal.get()); } }); } executor.shutdown(); System.out.println(threadLocal.get());}
每个线程都正确地读取出来了“保存到ThreadLocal中”的数据。
4.6.1 ThreadLocal的作用原理
在上面的程序中,我们将ThreadLocal定义为全局的静态变量,而且每次只要在指定的线程内部,调用了ThreadLocal的set()和get()方法就将值保存到了指定的线程的名下。
其实,实际数据的存储位置确实是在指定的线程内部的。
4.6.1.1 写操作
当我们在一个线程的内部,向一个ThreadLocal中写入数据的时候(调用set()方法),它要执行的逻辑如下:
public void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value);}
也就是说,我们先用Thread.currentThread()获取当前的线程,也就是调用set()方法的线程。然后,我们获取该线程内部的ThreadLocalMap变量。如果成功获取到了值,我们就将this(即被调用set()方法的ThreadLocal)和value作为一个类似键值对的整体插入到了map中。如果不存在的话,就先创建一个map,然后再将“键值对”插入进去。所以,实际上我们保存到ThreadLocal中的值就是存储到Thread内部的变量里的。而ThreadLocal在这里的作用是,当我们获取数据的时候,使用ThreadLocal来作为键从Therad内部的map中读取之前存储的数据。也就是仅仅充当一个键的作用。
4.6.1.2 读操作
以下是读取操作相关的逻辑:
public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) { ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings("unchecked") T result = (T)e.value; return result; } } return setInitialValue();}
从这里看出,它的确也是,先获取调用ThreadLocal的get()方法的线程,然后获取该线程内部的map,然后将当前的ThreadLocal(也就是this)作为键,从map中读取一个“键值对”,并从中读取保存的结果。
4.6.1.3 底层数据结构
上面我们说ThreadLocal的变量都是保存在Thread内部的一个变量ThreadLocalMap中,那它又是怎么存储的呢?
首先,它内部定义了类似于哈希表的结点的类,不过这里它继承了WeakReference:
static class Entry extends WeakReference<ThreadLocal<?>> { Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; }}
然后,是ThreadLocalMap的定义。从下面可以看出,它的内部定义了一个Entry类型的数组:
static class ThreadLocalMap { private Entry[] table; // ....}
这也是它实现哈希的一种方式,与HashMap和TreeMap不同的地方在于,这里实现的哈希存储是基于线性探测法来实现的。这也是上面定义数组的结点的原因,它不是链表形式的结点,所以仅仅是作为一个普通的数组的一个元素。当我们向该数组中存储元素的时候,也就是把一个“键值对”塞到数组里的时候,它实际上执行了下面的操作:
private void set(ThreadLocal<?> key, Object value) { ThreadLocal.ThreadLocalMap.Entry[] tab = table; int len = tab.length; // 这里倒是跟HashMap一样,它使用2的整数次幂来实现,将指定的ThreadLocal // 映射到数组的索引,这里是用来截取ThreadLocal的哈希码的后几位 int i = key.threadLocalHashCode & (len-1); // 这里是一个遍历操作,目的在于寻找与当前的ThreadLocal的哈希码相等的数组元素 // 它寻找的开始位置是前面计算得出的索引,然后在一个“键簇”中进行查找 for (ThreadLocal.ThreadLocalMap.Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal<?> k = e.get(); if (k == key) { e.value = value; return; // 注意,找到了更新之后就返回了 } if (k == null) { replaceStaleEntry(key, value, i); return; } } // 表示没有找到,新建一个结点并赋值给数组的指定元素 tab[i] = new ThreadLocal.ThreadLocalMap.Entry(key, value); // 散列表中存储的记录总数+1 int sz = ++size; // 如果已经存储的容量大于我们指定的容量,那么对数组进行扩容,并重新计算哈希值 if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash();}
以上就是ThreadLocal的基本用法和底层的实现的原理。
更多内容
1、该项目整理了设计模式、Java语法、JVM、SQL、数据结构与算法等相关内容:https://github.com/Shouheng88/Java-Programming。
2、由于时间仓促,不免于存在错误,欢迎批评指正。
- Java基础知识梳理--线程
- Java基础知识梳理
- java基础知识梳理
- Java基础知识梳理
- Java基础知识梳理--IO
- Java基础知识梳理--泛型
- Java基础知识梳理--注解
- JAVA基础知识梳理(一)
- JAVA基础知识梳理(二)
- JAVA基础知识梳理(三)
- JAVA基础知识梳理(四)
- java day03-day05 基础知识梳理
- 基础知识梳理
- Java知识点整理:第一章:基础知识梳理
- java基础知识(线程方面)
- java线程安全-基础知识
- java 线程基础知识
- java线程基础知识
- vue2-学习笔记之高仿饿了吗项目
- 实验4 按照满二叉树的特点生成一棵二叉树
- selenium爬虫and自动化测试
- Ubuntu14.04+QT3.0.1+opencv3.0.0alpha的人脸检测
- PAT Basic 1027
- Java基础知识梳理--线程
- mysql的查询、子查询及连接查询
- Linux系统LCD驱动架构分析
- Python爬虫学习纪要(八):Requests 库学习笔记3
- codeforces 894B
- 怎么获取到View的位置View.getLocationInWindow()的为0
- Java基础知识梳理--IO
- tensorflow初学代码系列一(基于莫烦视频)
- 实验5 哈夫曼树