只要打开招聘软件搜“Java开发”,你大概率会看到二十条JD里有十八条写着“Java基础扎实”。这句话听着像废话,可在面试的时候,基础扎实和不扎实的人,三两轮就试出来了。我这些年以面试官身份见过不少候选人,简历上写满Spring Boot、微服务、分布式,结果一句“HashMap为什么用2的幂次扩容”就卡壳。反过来,那些把数据类型、集合、并发这些最底层的东西吃透的人,看框架源码的时候明显更从容。这篇东西,我就从一个用了十年Java的老开发角度,聊聊基础学习到底学什么、怎么实操,顺便把大家常问的基础面试题和日常踩坑一起拆开看。
1. Java基础到底在学什么:先画一张知识地图
1.1 数据类型与基本语法:地基中的地基
很多新手翻开Java教材,第一章永远是“Hello World”。这当然没错,但我更建议你先从数据类型开始建立框架感。Java是一门静态强类型语言,所有变量在编译期就要确定类型。这个特点有两头好处,一头是编译器帮你拦下大量低级错误,另一头是你写代码时要时刻想清楚“这个变量到底存的是什么”。Python写顺手的人转Java,最初最不适应的往往就是这个。
Java的基本数据类型一共八种,我列个表放在这里,大家刚开始不用死记取值范围,但有两点必须留个印象:整数默认是int,小数默认是double;long和float的字面量要加L和F后缀,否则编译不过。
| 类型 | 字节数 | 默认值 | 大致取值范围 |
|---|---|---|---|
| byte | 1 | 0 | -128 ~ 127 |
| short | 2 | 0 | -32768 ~ 32767 |
| int | 4 | 0 | 约±21亿 |
| long | 8 | 0L | 很大 |
| float | 4 | 0.0f | 约±3.4E38 |
| double | 8 | 0.0d | 约±1.7E308 |
| char | 2 | '\u0000' | 0 ~ 65535 |
| boolean | 1位 | false | true / false |
除了基本类型,Java里还有引用类型。数组、String、各种对象都属于引用类型,引用类型的变量里存的是对象的地址,不是对象本身。可以这么理解:基本类型变量是“拿着钱直接买东西”,引用类型变量是“手里攥着一个遥控器,遥控器指向某台电视”。两个引用变量用==比较的时候,比较的是两个遥控器是不是指向同一台电视,而不是两台电视内容是否一样。能想清楚这个比喻,后面学equals才不会糊。
再来说包装类。集合里不能直接放int,必须用Integer,这就有了自动拆装箱。基础的考点集中在Integer缓存上:Integer a = 127和Integer b = 127用 == 比较返回true,换成128就返回false。原因很简单,Integer内部对-128到127之间的对象做了缓存。类似的问题还会出现在String比较里。这类题目很八股,但确实考察你知不知道“==到底在比什么”。
流程控制和数组部分,我不建议单独死背。if、for、while用起来不陌生,数组的唯一重点就是下标从0开始,以及数组和集合之间的转换。基础语法里真正值得多花时间的,是用两层for循环实现一个冒泡排序。它不是效率多高的算法,但你亲手写一遍,能彻底理解循环嵌套是怎么跑的,这也是很多公司让候选人手写排序的原因。
1.2 面向对象:Java的核心不是语法,是思想
面向对象是Java的根。类与对象的关系,我用图纸和汽车来理解:类是图纸,规定了车有什么属性、能做什么;对象是按图纸造出来的那一台台具体的车。你每new一次,就是照着图纸重新造了一辆车。图纸可以复用,车可以造很多台,这就是类与对象最朴素的含义。
三大特性是封装、继承、多态。封装的意义是隐藏内部细节,外部只能通过公开方法操作对象,这样内部状态不会被随意改坏。继承解决的是共同代码复用,但别滥用。我记得刚工作时总喜欢为了省代码硬造继承关系,结果一个类改字段,一串子类跟着崩。后来才明白,优先用组合,继承要非常克制。
接口和抽象类是面试题常客。抽象类更像“半成品”,里面可以放已经实现好的方法,也可以留下抽象方法让子类补全;接口更像一份“合同”,规定实现类必须提供哪些能力。Java 8之后接口里也能写default方法,但设计初衷没变:一个类只能继承一个抽象类,却可以实现多个接口。判断该用谁,就看是想复用代码,还是想定义能力。
多态这点值得多说一句。父类引用指向子类对象,调用方法时真正执行的是子类重写后的逻辑。听起来绕,但你在代码中到处都能看到:方法参数写成List,实际传进去的可能是ArrayList,也可能是LinkedList,调用方不关心具体类型,只关心它有List的能力。这就是多态带来的灵活度。重载和重写是两个概念,重载发生在编译期、重写发生在运行期,被问到时先分清这个,基本就能过。
基础里还有static、final、构造方法这些细碎概念。static让成员属于类而不是某个对象,final给变量、方法、类上锁。构造方法负责初始化对象,子类构造时必须先调用父类构造方法。这些点不太难,但组合起来能出很多面试题,比如static方法能不能重写、final类能不能继承。理解“属于类”和“属于对象”的差异,大多数都能想明白。
1.3 集合框架与常用API:基础里的主战场
集合是Java基础里最值得投入时间的部分,没有之一。体系可以一句话记住:Collection是单列集合的根接口,下面有List、Set、Queue;Map是独立体系,存键值对。ArrayList用的是动态数组,查询快、增删慢;LinkedList用的是双向链表,头尾增删快、随机访问慢。但实际开发里,绝大多数场景选ArrayList就够了,LinkedList的“优势场景”没那么常见。面试别把两者的复杂度背反。
ArrayList扩容是拿着现有数组长度算出新容量,一般是旧容量的1.5倍,再通过Arrays.copyOf把老数据拷进新数组。这个知识点面试常考,关键是“扩容是有代价的”,所以如果预估数据量大,初始化时直接指定容量,能省掉反复扩容的损耗。底层没多玄妙,能把new ArrayList()之后发生了什么讲明白,基础这一关就算过半了。
Map里最核心的就是HashMap。底层是数组加链表,JDK 8之后在链表过长时改成红黑树。put一个键值对时,先对key的hashCode做二次扰动,再和数组长度减一做位与运算定位到桶;哈希冲突了就往链表或红黑树里放。负载因子默认0.75,意思是数组用了75%就扩容,这是空间利用率和冲突概率之间折中的结果。为什么容量要求是2的幂次?因为这样能用位运算替代取模,扩容时元素重新落位也只需看新增的那个二进制位是0还是1。这些内容别死背,打开JDK源码顺着代码走一遍,记忆自然就深。
Stream和Lambda现在也算基础了。比如把一堆订单按金额过滤再求总和,用Stream写出来比传统for循环简洁得多。学习时不要只看语法,要理解函数式接口这个概念:Lambda本质是实现了某个函数式接口的匿名方法。StringBuilder在字符串拼接里的作用也要会对比,后面面试篇我会专门拆。
1.4 异常处理与泛型:写健壮代码的底线
异常处理看着简单,写出问题却很多。Java的异常继承自Throwable,下面分Error和Exception。Error一般指JVM层面的严重问题,比如OutOfMemoryError,正常代码不需要捕获;Exception又分检查型和非检查型,非检查型就是RuntimeException及其子类,空指针、数组越界都属于这一类。编译期不强制处理运行时异常,但这不代表你可以不处理,该留心的还是要try-catch或者提前校验。
try-with-resources是我特别想推荐给新手的写法。Java 7之后,只要资源实现了AutoCloseable,就可以写在try括号里,作用域结束自动关闭。以前写文件流,finally里关流又是一坨代码,还要处理close本身抛异常;现在这样写干净很多。还有一条铁律:catch里不要吞异常。见过太多人catch(Exception e){}然后排错排到崩溃,一查日志空白。至少得log.error或者把异常信息打印出来。
泛型的核心作用是在编译期做类型检查。比如List<String>只能放String,编译期就把类型问题拦住了。这里有个容易懵的点:类型擦除。JVM运行的时候,List<String>和List<Integer>的运行时类型其实都是List,泛型信息在编译后就被擦掉了。所以你不能用instanceof去判断一个List到底是String型还是Integer型。理解擦除之后,泛型方法、泛型通配符这些概念学起来会顺很多。
2. 从零开始的实操:环境搭建、第一个程序与学习路线
2.1 JDK安装与环境变量配置:不可跳过的第一步
配置环境永远是新手第一道坎。选版本先别纠结,用LTS版本。JDK 8目前存量项目还很常见,JDK 17和21是新项目的主流,学习阶段选8或17都行。如果跟着老教程走,就装8;如果不想之后重新适应,装17。我个人的建议是:时间充裕就装17,但一定要知道8的差异点,因为很多公司线上项目还跑在8上。
下载别随便在百度搜“JDK下载”点进不明站点。Oracle官方提供安装包,Adoptium的Temurin、Azul的Zulu这些开源发行版也很好,国内还可以用清华镜像或华为云镜像,下载速度更快。记得认准x64安装包,别下成ARM版。正确的下载地址比下载速度更重要,很多奇怪的问题都是装到来路不明的魔改JDK之后才出现的,这个坑我见得不少。
Windows下配置环境变量,右键“此电脑”→属性→高级系统设置→环境变量。新建系统变量JAVA_HOME,值填JDK安装根目录,比如C:\Program Files\Java\jdk-17;然后在Path里新增一条%JAVA_HOME%\bin。保存后重新开一个命令行窗口,分别执行java -version和javac -version,两个能输出同一个版本号,环境就算通了。Linux和macOS就改~/.bashrc或~/.zshrc,设置JAVA_HOME和PATH,用source命令重载。
环境变量配置完不生效,十有八九是没重开命令行;多个JDK共存时JAVA_HOME被别的软件改了,也会导致java -version和你预期不符。排查的时候先执行echo %JAVA_HOME%看指向是谁,再用where java看实际调用的是哪个路径。这两个命令能解决80%的环境问题,剩下20%基本是安装路径里有中文或空格,重装到纯英文目录就好。
2.2 第一个Java程序:理解编译与运行
有了环境,先别急着打开IDE,找一个纯文本编辑器写下这段代码:
public class HelloWorld { public static void main(String[] args) { System.out.println("Hello, Java!"); } }在终端里执行javac HelloWorld.java,如果没有任何输出,说明编译成功,目录下多了一个HelloWorld.class文件。再用java HelloWorld运行,注意不要带.class后缀。这一前一后两个步骤,你能看到Java的跨平台原理:编译一次生成字节码,之后只要目标机器装了对应版本的JVM,就能解释执行字节码。很多教程直接让你点IDE里的运行按钮,把这两个命令藏起来了,导致很多人搞不清Class文件是什么。
写代码工具,我推荐IntelliJ IDEA社区版。社区版免费,功能对学习阶段完全够用。新版本IDEA启动慢是通病,把没用的插件禁用能快不少。它也内置了psvm、sout这些快捷模板,新建类之后直接敲psvm回车,主方法就出来了。不过基础阶段我建议偶尔关掉自动补全,纯手写几段代码找找语法感觉,不然依赖IDE太强,面试笔试手写代码时会露怯。
还有一个很多人忽略的点:package和目录结构是对应的。比如你在代码里写了package com.example.demo,这个.java文件最好放在com/example/demo目录下。IDE会自动帮我们维护,但用命令行编译时,搞错目录就会报“找不到类”的错误。理解这个对应关系,以后看Maven项目结构会轻松很多。
2.3 学习路线怎么排:从语法到框架的合理坡度
我按阶段拆一下学习路线。
- 第一阶段是Java SE核心:语法、面向对象、集合、IO、多线程、反射和JVM基础。这个阶段不用追求一次全会,但集合和多线程至少要达到能解决常见需求的程度。
- 第二阶段是数据库和基础工具:MySQL的SQL语句、JDBC,配合HTML/CSS/JavaScript简单了解。这个阶段可以做最简单的用户管理系统,体验前后端交互。
- 第三阶段是Java Web:Servlet、Tomcat、Maven,了解HTTP协议和请求响应模型。
- 第四阶段是主流框架:Spring、Spring Boot、MyBatis,用框架快速搭项目。
- 第五阶段是中间件和项目实战:Redis、消息队列、分布式基础,做几个能写进简历的项目。
我说点大实话:如果你Java基础还很虚,别急着上Spring Boot。框架的本质是工具,工具用得再熟,底层问题一出现还是会卡住。我见过太多简历写着“熟练使用Spring Boot”的人,连Bean生命周期是什么都说不清。基础这一关,没有必要跨过去,也跨不过去。
刷题网站我常用力扣和牛客。力扣的easy和medium对新手足够,牛客上有很多Java面试题合集,适合系统复习。但刷题不是目的,遇到不会的要能反推到知识点。我的办法是建一个问答式笔记,把问题写下来,过几天不看资料自己回答一遍,回答不出的地方就是薄弱点,这个办法比盲目收藏好得多。也建议不要直接抄网上的课程设计源码,自己分析需求、拆分模块、一步步实现,收获完全不同。
3. 高频Java基础面试题背后的原理拆解
3.1 String、StringBuilder与StringBuffer三兄弟
三个类放一起讲,几乎是我面试必问的一组。
String不可变,底层在JDK 8是final修饰的char数组,JDK 9及以后换成了byte数组。为什么不可变?安全是一个原因,String经常当参数传给文件路径、类名、网络地址,如果可变,这些场景的状态就不可控;另一个原因是字符串常量池复用,不可变才能安全缓存;还有哈希值,String对象可以缓存自己的哈希值,频繁用Map时效率更高。
字符串用+拼接时,编译器会把它转成StringBuilder的append链。注意,单行语句没问题,循环里每走一次都会new一个StringBuilder,循环次数多了就会浪费大量对象。所以循环拼接字符串,直接在外面new一个StringBuilder,循环里append,最后toString。
| 对比项 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 可变性 | 不可变 | 可变 | 可变 |
| 线程安全 | 安全 | 不安全 | 安全(方法加锁) |
| 性能 | 最差 | 最高 | 较低 |
| 适用场景 | 常量或少量拼接 | 单线程、循环拼接 | 多线程共享 |
实际开发里,方法内的字符串拼接直接用StringBuilder就够了,因为局部变量没有线程安全问题。StringBuffer的同步锁反而成为累赘。
3.2 == 与 equals,以及 hashCode 的约定
==比较的是栈里的值。基本类型比较的是数值本身;引用类型比较的是引用地址,也就是那个遥控器指向哪台电视。equals是Object里的方法,默认实现和==一样,比较地址,但很多类重写了它,比如String重写后比较的是字符内容。所以用==比较两个String内容,除非它们指向同一个常量池对象或intern过,否则就是false;业务比较用equals。
重写equals必须同时重写hashCode。约定是:equals相等,hashCode必须相等;hashCode相等,equals未必相等。为什么必须有这个约定?因为HashMap、HashSet这些容器靠hashCode先定位桶,再用equals判断桶里到底是不是同一个对象。如果你只重写equals不重写hashCode,同一个对象可能被丢进不同的桶,contains就永远返回false。这是很多新手在集合里查不到对象的根本原因。
public class User { private String name; // 只重写equals,不重写hashCode // hashCode继承自Object,默认是地址 }这个User放到HashSet里,new两个name相同的对象,第二次add都可能成功,因为两个对象的地址不同。要把对象正确放入HashSet、HashMap的Key位置,就必须成对重写equals和hashCode。
3.3 HashMap 的底层结构与扩容机制
HashMap是Java面试的重灾区,网上八股一大堆。核心就几条:底层是数组加链表,JDK 8后链表长度超过8且数组容量不小于64时转红黑树;put走hashCode二次扰动定位桶;负载因子0.75;容量始终保持2的幂次;size超过数组长度乘0.75时扩容为原来的两倍。
数组下标为什么用位运算而不是取模?当n是2的幂次时,n-1的低位全是1,hash & (n-1)就等价于hash % n,但位运算更快。还有一个好处是扩容时元素迁移简单:扩容后容量翻倍,元素的新位置要么不变,要么加旧容量。老版本HashMap并发扩容时可能形成循环链表,这也是HashMap线程不安全的著名坑。
多线程场景不要用HashMap,直接用ConcurrentHashMap。JDK 8里它用CAS加锁加锁桶的方式来保证并发安全,性能远好于HashTable。面试中常问的HashTable是全方法加锁,现在基本不再使用。基础阶段知道这个演进过程即可,不必把每个版本的实现细节都背下来。
3.4 并发基础:volatile、synchronized 与 AQS
并发是Java基础的深水区,也是拉开差距的地方。先理解Java内存模型:线程操作数据时,不是直接改主内存里的值,而是先复制到自己的工作内存,操作完再写回。这个机制会造成一种现象:线程A改了变量,线程B可能看不到最新值,这就是可见性问题。
volatile可以解决可见性和禁止指令重排问题,但不保证原子性。典型用法是状态标志位,一个线程改标志,另一个线程读取,加volatile就够了。但如果是count++这种“读-改-写”的操作,volatile就无能为力,因为三个动作可以被打断。原子性要靠synchronized或者原子类,比如AtomicInteger。
synchronized是Java内置的锁,可以锁对象、锁方法、锁类。JDK 6之后引入了锁升级:偏向锁、轻量级锁、重量级锁。你不需要把每个细节背熟,但要能解释清楚它本质上是互斥锁,保证临界区内的原子性和可见性。日常开发中,加锁范围越小越好,能用局部变量就不用成员变量,能用并发工具就不用synchronized。
回到“Java怎么保证数据一致性”这个问题。要保证共享数据在多线程环境下一致,靠的是原子性、可见性、有序性三个维度。synchronized和Lock解决原子性并保证可见性;volatile解决可见性和有序性;CAS是乐观锁思路,适合竞争不激烈的情况。AQS是JUC锁和同步工具的底层骨架,内部有state状态字段表示资源占用情况,配合一个等待队列,线程抢不到资源就排队等待,抢资源的过程用CAS更新state。ReentrantLock、CountDownLatch、Semaphore都是基于AQS封装出来的。基础阶段理解到这个层面,已经能超过不少工作了几年的人。
3.5 手写基础:冒泡排序与单例模式
手写代码是面试基础关。冒泡排序的写法,我建议背到不用思考就能写出来,但不只是背,要能讲清楚为什么外层循环控制轮数、内层循环控制比较范围。
public static void bubbleSort(int[] arr) { int n = arr.length; for (int i = 0; i < n - 1; i++) { boolean swapped = false; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = true; } } if (!swapped) { break; } } }加上一个swapped标记,某一轮没发生任何交换就提前结束,这是最基础的优化。复杂度最好情况O(n),最坏和平均O(n^2)。很多人把复杂度说错,其实只要想明白最坏情况下内层循环比较次数接近n(n-1)/2,自然就记住了。
单例模式有饿汉、懒汉、双重检查锁、静态内部类、枚举这几种写法。面试最爱问的是双重检查锁为什么加volatile。
public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }instance = new Singleton()在字节码层面不是一步,而是分配内存、初始化对象、把引用赋给变量。如果没有volatile,指令重排后可能先赋值引用,对象还没初始化完成,另一个线程拿到的就是个未初始化完成的对象。第一次判空为了省锁,第二次判空保证在锁内只创建一次。这题考察的知识点覆盖了内存模型、指令重排、锁三级,非常综合。
4. 实战中的坑与排查实录
4.1 环境与编译期的坑
环境相关的坑,我自己刚学的时候也踩过不少,而且每一条都特别浪费人生。先说一个最经典的:你按照教程配好了JAVA_HOME和PATH,高高兴兴打开命令行跑java -version,结果提示找不到命令,或者显示的版本和刚装的JDK完全不一致。这种问题几乎每个初学者都遇过,原因多半是命令行窗口没有重开,或者系统变量和用户变量混在一块,导致系统加载了旧配置。
第二个高频问题是多个JDK共存。电脑里装过JDK 8又装了JDK 17,明明JAVA_HOME指向17,java -version却还是1.8。这时候用echo %JAVA_HOME%和where java两个命令排查,基本能找到罪魁祸首。很多软件安装时会自动把自家的JDK路径写进PATH,而且排在前面的优先被执行,把%JAVA_HOME%\bin往前提一格就能解决。
还有一类是编码问题。Windows控制台默认GBK,代码文件是UTF-8,直接命令行javac编译带中文的代码,会报“编码GBK的不可映射字符”。解决方法是javac -encoding UTF-8 HelloWorld.java,或者在IDE里把全局编码统一成UTF-8。这类问题别看小,一旦出现会直接卡住学习进度。
| 现象 | 原因 | 解决方式 |
|---|---|---|
| 改完环境变量仍不生效 | 命令行缓存未刷新 | 重新打开命令行 |
| java -version和javac -version不一致 | PATH中存在多个JDK | where java / where javac排查 |
| IDEA编译报source/target版本冲突 | 项目SDK和编译器设置不一致 | 统一Project Structure设置 |
| 编译中文报错 | 编码不统一 | 加-encoding UTF-8或全局UTF-8 |
排查环境问题的本质是分清两件事:程序写错了,还是环境错了。先跑最简单的HelloWorld,再单独用命令行编译,就能把范围缩小。不要一上来就怀疑电脑坏了,大部分环境问题都是路径、版本、编码三件事引起的。
4.2 编码过程中的经典坑
编码层面的坑更多,要么是并发修改,要么是空指针。
遍历List并删除元素,新手最常见。用fori的时候,删除一个元素后索引没回退,后面的元素会跳过;用foreach的时候直接抛ConcurrentModificationException。正确姿势是用Iterator的remove,或者JDK 8之后的removeIf。之所以抛异常,是因为foreach隐式用了Iterator,而modCount被修改后,预期的modCount对不上就触发fail-fast。这个机制不是设计缺陷,是快速失败的防线。
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c")); // 错误:foreach删除 for (String s : list) { if (s.equals("a")) { list.remove(s); // 抛异常 } }自动拆箱空指针也是一个容易踩的坑:Integer x = null; int y = x;直接抛NPE。通常发生在从Map里get一个不存在的value,然后直接参与运算。所以对象转基本类型之前,一定要先判空。Integer的==比较也常见:两个Integer用==比较,在-128到127之间走缓存返回true,超出这个范围就是两个对象,比的是地址。要比较数值,用intValue()或者equals。
还有switch的null处理。早期Java版本里,switch表达式如果是null,会直接抛NullPointerException。有人问“java switch 空数据”,其实就是在问这个事。新版JDK里开始支持case null,但你自己写业务代码时不要依赖这个新特性,入口判空更稳妥。
Arrays.asList返回的List也有点特殊,它是最简单的固定大小列表,本身是Arrays里的一个内部类,不是java.util.ArrayList,调用add或remove会抛UnsupportedOperationException。想转成可变列表,用new ArrayList<>(Arrays.asList(...))包一层。
4.3 学习方法上的坑与建议
技能层面的坑可以查文档解决,学习方法上的坑只能靠自己调整。我见过太多人每天看视频、记笔记,一问到具体代码就懵。看一万行代码,不如自己手写一百行。视频里老师写一遍你跟着写一遍,好像会了,但关掉视频让你从头写一个类似的小功能,能写出来才是真会。建议每学一个知识点,就给自己出一个小任务。
源码阅读也是新手容易走偏的地方。一上来就逐行读HashMap源码,读两天放弃。正确打开方式是先看类注释、核心字段和put/get方法的主流程,自己画流程或者口述一遍,再回来看细节。目标是建立心智模型,不是背代码。
还要善用调试器。在IDEA里给一段代码打上断点,逐行看变量变化,比抽象地想“这里应该是什么”直观得多。很多人觉得调试是工作之后才需要的能力,其实基础阶段就练,收益巨大。理解递归的栈过程、ArrayList扩容时数组的变化、HashMap put时桶的定位,这些只用打印输出就能看清,关键是你愿不愿意动手。
最后是积累自己的代码片段库。我工作这些年,最常用的工具类、异常处理模板、集合操作片段都存在本地文件里,需要时直接改改就用。新手也可以这样,每学一个API,写一个小demo存起来,过段时间翻出来,就是自己的文档。这个习惯越早建立越受益。
回到Java基础学习这件事,我自己的体会是,它不是一个学完就翻篇的章节,而是一个会反复加固的底座。等你接触Spring Boot、微服务之后回头看,很多困惑都会牵回基础。我每隔一段时间会重新读一遍HashMap和AQS源码,每次都有新理解。如果你现在正卡在基础阶段,别急着横向铺开,把数据类型、面向对象、集合、并发这几块亲手写一遍、调试一遍,比空看十遍教程有用。慢慢来,反而快。