[A.I System Programming] B5: Chương trình Cuda C

This entry is part 5 of 6 in the series A.I System Programming

Sơ lược

CUDA là một nền tảng tính toán song song và mô hình lập trình do NVIDIA phát triển. Nền tảng này cho phép gia tăng đáng kể hiệu suất tính toán bằng cách khai thác sức mạnh của bộ xử lý đồ họa (GPU). CUDA cho phép dev viết chương trình tăng tốc các ứng dụng tiêu tốn nhiều tài nguyên tính toán bằng cách sử dụng các ngôn ngữ lập trình phổ biến như C, C++, và Fortran

image 107 - quochung.cyou PTIT

Ngôn ngữ CUDA C mở rộng ngôn ngữ lập trình ANSI C phổ biến với một lượng tối thiểu các cú pháp mới và các hàm thư viện. Như tên gọi của nó, CUDA C được xây dựng trên nền tảng CUDA của NVIDIA. Hiện nay, CUDA là framework phổ biến nhất cho tính toán song song khối lượng lớn, được sử dụng rộng rãi trong ngành tính toán hiệu năng cao (HPC) với các công cụ thiết yếu như trình biên dịch, trình gỡ lỗi và trình phân tích hiệu năng có sẵn trên các hệ điều hành phổ biến nhất.

Cấu trúc của một chương trình CUDA C phản ánh sự cùng tồn tại của một máy chủ (host) chính là CPU và một hoặc nhiều thiết bị (devices) chính là các GPU trong máy tính. Mỗi tệp mã nguồn CUDA C có thể chứa hỗn hợp cả mã cho máy chủ (host code) và mã cho thiết bị (device code).

  • Theo mặc định, bất kỳ chương trình C truyền thống nào cũng là một chương trình CUDA chỉ chứa mã máy chủ.
  • Ta có thể thêm mã thiết bị vào bất kỳ tệp nguồn nào. Mã thiết bị được đánh dấu rõ ràng bằng các từ khóa CUDA C đặc biệt.
  • Mã thiết bị bao gồm các hàm, hay còn gọi là các kernel, mã của chúng được thực thi theo cách thức song song dữ liệu.
image 108 - quochung.cyou PTIT

Như hình trên ta thấy quá trình bắt đầu với mã máy chủ (mã tuần tự trên CPU).

  • Khi một hàm kernel được gọi, một số lượng lớn các luồng (threads) sẽ được kích hoạt (launched) trên một thiết bị để thực thi kernel đó.
  • Tất cả các luồng được kích hoạt bởi một lần gọi kernel được gọi chung là một lưới (grid). Những luồng này là phương tiện thực thi song song chính trong nền tảng CUDA.
  • Khi tất cả các luồng của một lưới đã hoàn thành việc thực thi, lưới đó sẽ kết thúc và việc thực thi tiếp tục trên máy chủ cho đến khi một lưới khác được kích hoạt.

Đây là một mô hình đơn giản hóa, trong đó việc thực thi của CPU và GPU không chồng chéo lên nhau. Nhiều ứng dụng tính toán không đồng nhất thực tế sẽ quản lý việc thực thi chồng chéo giữa CPU và GPU để tận dụng tối đa sức mạnh của cả hai.

Một kernel cộng vectơ (vector addition)

Bản CPU

Ta sử dụng phép cộng vectơ để minh họa cấu trúc chương trình CUDA C. Cộng vectơ là phép tính song song dữ liệu đơn giản nhất có thể – tương đương với chương trình “Hello World” của lập trình tuần tự. Trước khi xem mã kernel, ta hãy xem xét hàm cộng vectơ truyền thống (chạy trên CPU).

Mục tiêu: Cho hai vector A, B có kích thước n phần tử. Ta cần tính tổng từng phần tử của hai vector và cho kết quả ở vector C, với C[i] = A[i] + B[i] cho mọi 0 ≤ i < n.

Ví dụ:

n = 3
Vector A: 1 2 3
Vector B: 4 5 6
Vector C: 5 7 9
image 109 - quochung.cyou PTIT

Hình trên cho thấy một chương trình C đơn giản gồm hàm main và hàm cộng vectơ vecAdd.

Quy ước đặt tên: Trong tất cả các ví dụ, khi cần phân biệt giữa dữ liệu của máy chủ và thiết bị, ta sẽ thêm hậu tố _h (CPU) cho các biến dùng bởi máy chủ và _d cho các biến dùng bởi thiết bị (GPU).

Như đã thảo luận ở phần 2, các tham số số của hàm vecAdd – A, B và C là các con trỏ.

  • Tại đây ở phần comment // Memory allocation for arrays A, B, and C thì ta sẽ khởi tạo các mảng phần tử A, B, C. Sau đó, khi truyền tham số vào vecAdd, ta đang truyền 1 con trỏ với cú pháp float *A_h
  • Ôn tập: Một mảng trong chương trình C có thể được truy cập thông qua một con trỏ trỏ đến phần tử thứ 0 của nó. Ví dụ, câu lệnh P=&(A[0]) làm cho P trỏ đến phần tử thứ 0 của mảng A. Lúc này P[i] tương đương với A[i]. Thực tế, chính tên mảng A cũng là một con trỏ trỏ đến phần tử thứ 0 của nó. (Có thể xem lại bài 2)

Trong hình, việc truyền tên mảng A vào hàm vecAdd làm cho tham số đầu tiên A_h của hàm trỏ đến phần tử thứ 0 của A. Kết quả là A_h[i] trong thân hàm có thể truy cập vào A[i] của hàm main.

Hàm vecAdd trong hình sử dụng một vòng lặp for để duyệt qua các phần tử của vectơ. Ở lần lặp thứ i, phần tử đầu ra C_h[i] nhận giá trị tổng của A_h[i]B_h[i]. Tham số độ dài vectơ n được dùng để điều khiển vòng lặp.

Bản CPU

Giờ ta hãy xem cách để thực thi cộng vectơ song song, cụ thể ta sẽ sửa đổi hàm vecAdd và chuyển các tính toán của nó sang một thiết bị. Cấu trúc của hàm vecAdd đã sửa đổi được trình bày trong ảnh dưới

image 110 - quochung.cyou PTIT
  • Phần 1: Cấp phát không gian trong bộ nhớ thiết bị (GPU) để chứa các bản sao của các vectơ A, B và C, sau đó sao chép vectơ A và B từ bộ nhớ máy chủ sang bộ nhớ thiết bị.
  • Phần 2: Gọi kernel cộng vectơ thực sự để kích hoạt một lưới các luồng trên thiết bị.
  • Phần 3: Sao chép vectơ tổng C từ bộ nhớ thiết bị về bộ nhớ máy chủ và giải phóng bộ nhớ của ba mảng này trên thiết bị.

Bộ nhớ toàn cục của thiết bị và truyền dữ liệu

Trong các hệ thống CUDA hiện nay, các thiết bị (GPU) thường là các bảng mạch phần cứng đi kèm với bộ nhớ truy cập ngẫu nhiên động (DRAM) riêng biệt (hay còn gọi là VRAM của GPU), được gọi là bộ nhớ toàn cục của thiết bị (device global memory), hoặc gọi ngắn gọn là bộ nhớ toàn cục.

Ví dụ: NVIDIA Volta V100 đi kèm với 16GB hoặc 32GB bộ nhớ toàn cục.

Việc gọi đây là bộ nhớ “toàn cục” nhằm phân biệt nó với các loại bộ nhớ thiết bị khác mà lập trình viên cũng có thể truy cập được

Đối với kernel cộng vectơ, trước khi gọi kernel, thực tế ta thực hiện các bước sau

  1. Cấp phát không gian trong bộ nhớ toàn cục của thiết bị (GPU).
  2. Truyền dữ liệu từ bộ nhớ máy chủ (CPU) sang không gian đã cấp phát đó.

Tương tự, sau khi thiết bị thực thi xong, ta cần:

  • Giải phóng không gian bộ nhớ toàn cục đã cấp phát khi không còn cần thiết.
  • Truyền dữ liệu kết quả từ bộ nhớ toàn cục của thiết bị trở lại bộ nhớ máy chủ

Khi ta nói Host CPU sẽ có bộ nhớ riêng, thông thường trong một hệ thống máy tính, đó chính là Ram. Và các thiết bị (Device) GPU cũng sẽ có bộ nhớ riêng, hay thường được gọi là thông số VRAM của GPU. Thông thường thì GPU không thể sử dụng các biến trên bộ nhớ CPU (Ram) để làm việc (trừ 1 số ngoại lệ sẽ được thảo luận sau), vì vậy như ảnh sơ đồ một luồng cơ bản ban đầu, ta thường cần khởi tạo các biến tại máy chủ CPU trước, rồi copy dữ liệu sang GPU các dữ liệu cần dùng trong tính toán tại GPU, rồi sau khi tính toán xong, ta sẽ copy lại.

Hệ thống CUDA cung cấp các hàm API để thực hiện các hoạt động này thay cho lập trình viên. Thuật ngữ “truyền dữ liệu từ máy chủ sang thiết bị” sẽ được hiểu là sao chép dữ liệu từ bộ nhớ máy chủ (RAM của CPU) sang bộ nhớ toàn cục của thiết bị (VRAM của GPU).

Quản lý bộ nhớ: cudaMalloc và cudaFree

image 111 - quochung.cyou PTIT

Hình trên giới thiệu hai hàm API dùng để cấp phát và giải phóng bộ nhớ toàn cục của thiết bị.

  • cudaMalloc(): Có thể được gọi từ mã máy chủ để cấp phát một vùng bộ nhớ toàn cục cho một đối tượng. Hàm này có sự tương đồng rất lớn với hàm malloc của thư viện C tiêu chuẩn.
    • Tham số thứ nhất:địa chỉ của một biến con trỏ. Biến con trỏ này sẽ được thiết lập để trỏ đến đối tượng vừa được cấp phát. Địa chỉ của biến con trỏ phải được ép kiểu thành (void **) vì hàm này mong đợi một con trỏ vạn năng (generic pointer). Điều này cho phép cudaMalloc ghi địa chỉ của vùng nhớ vừa cấp phát vào biến con trỏ của ta bất kể kiểu dữ liệu của nó là gì.
    • Tham số thứ hai: Xác định kích thước dữ liệu cần cấp phát, tính bằng số byte.
  • cudaFree(): Giải phóng không gian lưu trữ khỏi bộ nhớ toàn cục. Nó chỉ cần giá trị của con trỏ (địa chỉ vùng nhớ cần giải phóng) làm đối số, do đó không cần truyền địa chỉ của con trỏ như cudaMalloc.

Ví dụ minh họa:

C
float *A_d;
int size = n * sizeof(float);
cudaMalloc((void**)&A_d, size);
// ... thực hiện tính toán ...
cudaFree(A_d);

Trong ví dụ này, ta dùng hậu tố _d cho con trỏ A_d để chỉ rõ nó trỏ đến một đối tượng trong bộ nhớ thiết bị. Khi cudaMalloc trả về, A_d sẽ chứa địa chỉ vùng nhớ trên GPU dành cho vectơ A. Lưu ý rằng khi tính toán size, ta phải chuyển đổi từ số lượng phần tử sang số byte (ví dụ: n phần tử kiểu float sẽ chiếm n lần 4 byte).

Các địa chỉ trong A_d, B_d, và C_d trỏ đến các vị trí trong bộ nhớ toàn cục của thiết bị. Ta không được phép giải mã (dereference) các con trỏ này trong mã máy chủ (CPU). Việc truy cập trực tiếp nội dung con trỏ thiết bị từ CPU sẽ gây ra lỗi thực thi hoặc ngoại lệ hệ thống.

Truyền dữ liệu: cudaMemcpy

Sau khi đã cấp phát không gian trên thiết bị, ta cần chuyển dữ liệu vào đó. Hàm API thực hiện việc này là cudaMemcpy.

image 112 - quochung.cyou PTIT

Hình trên mô tả hàm cudaMemcpy với bốn tham số:

  1. Đích (Destination): Con trỏ tới vị trí đích của đối tượng dữ liệu cần sao chép.
  2. Nguồn (Source): Con trỏ tới vị trí nguồn của dữ liệu.
  3. Kích thước (Size): Số byte cần sao chép.
  4. Loại truyền dẫn (Kind/Direction): Xác định các loại bộ nhớ liên quan:
    • cudaMemcpyHostToDevice: Từ máy chủ sang thiết bị.
    • cudaMemcpyDeviceToHost: Từ thiết bị về máy chủ.
    • cudaMemcpyDeviceToDevice: Giữa hai vị trí trong bộ nhớ thiết bị.
    • cudaMemcpyHostToHost: Giữa hai vị trí trong bộ nhớ máy chủ.

Áp dụng vào ví dụ vecAdd: Ta thực hiện sao chép các vectơ AB sang thiết bị trước khi tính toán, và sao chép kết quả C về máy chủ sau khi hoàn tất:

C
cudaMemcpy(A_d, A_h, size, cudaMemcpyHostToDevice);
cudaMemcpy(B_d, B_h, size, cudaMemcpyHostToDevice);
// ... (Gọi kernel thực hiện cộng vectơ ở đây) ...
cudaMemcpy(C_h, C_d, size, cudaMemcpyDeviceToHost);
image 113 - quochung.cyou PTIT

Tổng quan, chương trình hiện tại của ta có thể được thấy như sau, chỉ còn thiếu 1 phần gọi kernel để thực hiện việc tính toán.

Các hàm kernel và phân luồng

Trong CUDA C, một kernel là một hàm sẽ được thực thi song song bởi nhiều luồng trên thiết bị. Ta định nghĩa một kernel bằng cách thêm từ khóa khai báo hàm __global__ vào trước định nghĩa hàm.

Khi ta gọi một hàm __global__, nó sẽ được thực thi trên thiết bị. Một điểm đặc biệt là các hàm __global__ phải trả về kiểu void. Ta hãy xem xét ví dụ về kernel cộng vectơ trong hình sau:

image 114 - quochung.cyou PTIT

Hàm vecAddKernel trong hình trông rất giống với vòng lặp trong hàm vecAdd ở ví dụ CPU, nhưng có hai điểm khác biệt quan trọng:

  1. Từ khóa __global__ thông báo cho trình biên dịch rằng đây là một kernel.
  2. Vòng lặp for đã biến mất.

Tại sao vòng lặp lại biến mất? Đó là vì khi kernel này được kích hoạt, một lưới các luồng sẽ được tạo ra. Ta cấu trúc chương trình sao cho mỗi luồng trong lưới sẽ thực hiện một lần lặp của vòng lặp ban đầu. Để làm được điều này, mỗi luồng cần biết nó phải xử lý phần tử nào của các vectơ A, B và C.

Biến tích hợp và định danh luồng (Thread Identification)

CUDA C cung cấp các biến tích hợp sẵn (built-in variables) để giúp các luồng xác định danh tính của mình. Một trong những biến quan trọng nhất là threadIdx. Trong ví dụ đơn giản này, ta sử dụng threadIdx.x để lấy chỉ số của luồng trong một khối (block).

Câu lệnh sau trong kernel:

int i = threadIdx.x;

cho phép mỗi luồng tự gán cho mình một giá trị i khác nhau dựa trên chỉ số của nó. Nếu ta kích hoạt n luồng, thì luồng thứ 0 sẽ có i=0, luồng thứ 1 sẽ có i=1, và cứ tiếp tục cho đến luồng thứ n-1. Nhờ đó, mỗi luồng sẽ thực hiện phép cộng trên một cặp phần tử khác nhau của các vectơ:

if (i < n) C_d[i] = A_d[i] + B_d[i];

Câu lệnh if (i < n) là một biện pháp bảo vệ, Nó đảm bảo rằng nếu số lượng luồng được kích hoạt lớn hơn độ dài vectơ n, các luồng “thừa” sẽ không truy cập vào vùng nhớ ngoài phạm vi của mảng.

image 115 - quochung.cyou PTIT

SPMD (Single Program, Multiple Data)

Mô hình thực thi này được gọi là SPMD (Một chương trình, nhiều dữ liệu). Tất cả các luồng đều thực thi cùng một mã nguồn (hàm kernel), nhưng mỗi luồng hoạt động trên một phần dữ liệu khác nhau dựa trên định danh của nó. Đây là cách CUDA khai thác tính song song dữ liệu trên quy mô lớn.

Gọi hàm kernel

Khi ta gọi một kernel từ mã máy chủ, ta cần cung cấp các tham số cấu hình thực thi. Các tham số này xác định số lượng luồng trong lưới sẽ được kích hoạt để thực thi kernel đó.

CUDA C sử dụng cú pháp ngoặc nhọn ba lớp <<< ... >>> để bao quanh các thông số này. Cú pháp tổng quát như sau:

tên_kernel<<<số_khối, số_luồng_mỗi_khối>>>(các_đối_số);

Trong ví dụ cộng vectơ, lệnh gọi kernel có dạng:

vecAddKernel<<<1, n>>>(A_d, B_d, C_d, n);

Ở đây, ta đang yêu cầu hệ thống kích hoạt 1 khối duy nhất chứa n luồng.

  • Tham số thứ nhất (1) xác định số lượng khối trong lưới.
  • Tham số thứ hai (n) xác định số lượng luồng trong mỗi khối.

Khi lệnh gọi này được thực thi, môi trường chạy CUDA (runtime) sẽ tạo ra một lưới gồm n luồng trên thiết bị. Mỗi luồng sẽ thực thi mã của vecAddKernel với một giá trị threadIdx.x duy nhất từ 0 đến n-1.

Ta sẽ thảo luận sau cách sử dụng nhiều khối để xử lý các tập dữ liệu cực lớn vượt quá giới hạn số lượng luồng của một khối đơn lẻ.

Biên dịch (Compilation)

Quy trình biên dịch một chương trình CUDA C khác với chương trình C thông thường vì nó chứa cả mã chạy trên CPU (host) và mã chạy trên GPU (device).

  1. Trình biên dịch NVCC: NVIDIA cung cấp một trình biên dịch tên là nvcc. Nó sẽ phân tách mã máy chủ và mã thiết bị trong tệp nguồn (thường có phần mở rộng là .cu).
  2. Xử lý mã máy chủ: Mã máy chủ là mã C/C++ tiêu chuẩn, nó sẽ được gửi đến trình biên dịch C/C++ thông thường của hệ thống (như gcc trên Linux hoặc cl.exe trên Windows) để biên dịch và liên kết.
  3. Xử lý mã thiết bị: Mã thiết bị (các hàm kernel và các hàm bổ trợ) sẽ được nvcc biên dịch thành một dạng trung gian gọi là PTX (Parallel Thread Execution), hoặc trực tiếp thành mã máy nhị phân cho một kiến trúc GPU cụ thể.
  4. Hợp nhất: Cuối cùng, nvcc sẽ chèn các lệnh cần thiết để nạp mã thiết bị vào GPU và kích hoạt kernel vào trong mã máy chủ, tạo ra một tệp thực thi duy nhất.

Khi ta chạy tệp thực thi này, phần mã máy chủ sẽ bắt đầu chạy trên CPU. Khi gặp lệnh gọi kernel, hệ thống sẽ sử dụng mã thiết bị đã được đóng gói sẵn để kích hoạt các luồng trên GPU.

Ta đã làm quen với những khái niệm nền tảng nhất của lập trình song song trên nền tảng CUDA. Dưới đây là những điểm quan trọng mà ta cần ghi nhớ để làm tiền đề cho các kỹ thuật tối ưu hóa phức tạp hơn:

  • Song song hóa dữ liệu (Data Parallelism): Đây là chìa khóa để đạt được hiệu suất cao. Khi một công việc lớn có thể chia nhỏ thành các phần độc lập (như xử lý từng điểm ảnh hoặc từng phần tử vectơ), ta có thể tận dụng hàng nghìn lõi xử lý của GPU để thực thi chúng cùng lúc.
  • Mô hình Máy chủ – Thiết bị (Host – Device): Một chương trình CUDA luôn có sự phân chia vai trò rõ rệt. CPU (Host) đóng vai trò điều khiển, quản lý luồng công việc, trong khi GPU (Device) đóng vai trò là bộ tăng tốc, thực thi các tác vụ tính toán nặng.
  • Quản lý bộ nhớ: Ta đã học cách sử dụng các hàm API của CUDA để quản lý vòng đời của dữ liệu trên GPU:
    • cudaMalloc: Cấp phát không gian trên thiết bị.
    • cudaMemcpy: Chuyển dữ liệu qua lại giữa RAM máy chủ và VRAM thiết bị.
    • cudaFree: Giải phóng tài nguyên sau khi sử dụng.
  • Kernel và Luồng (Threads): Kernel là hàm chạy trên GPU. Khi kích hoạt một kernel, ta không chỉ gọi một hàm đơn thuần mà là đang kích hoạt cả một lưới (grid) gồm rất nhiều luồng. Mỗi luồng sử dụng các biến như threadIdx để biết mình cần xử lý phần dữ liệu nào.
  • Mô hình SPMD: CUDA sử dụng mô hình “Một chương trình, nhiều dữ liệu”, giúp đơn giản hóa việc lập trình song song bằng cách viết một đoạn mã duy nhất nhưng cho phép hàng triệu luồng thực thi nó trên các vùng dữ liệu khác nhau.

[A.I System Programming] B3: Lịch sử của máy tính

This entry is part 3 of 6 in the series A.I System Programming

Máy tính là gì?

Khi chúng ta hình dung về “một chiếc máy tính” ngày nay, chúng ta có lẽ sẽ nghĩ đến một thiết bị như máy tính để bàn (PC), máy chơi game cầm tay hoặc điện thoại thông minh. Nhưng đó không phải là những cỗ máy duy nhất mà con người từng sử dụng để tính toán và điện toán.

Để truy vết lịch sử của máy tính, trước tiên chúng ta cần quyết định xem cái gì được coi là máy tính và máy tính khác với những chiếc máy tính bỏ túi hoặc máy tính toán thuần túy như thế nào. Đây là một câu hỏi khó đến ngạc nhiên

Máy tính phải là thiết bị điện tử?

Hãy cùng xem xét thêm một số khái niệm thường được đề xuất để định nghĩa máy tính. Từ điển tiếng Anh Oxford, yêu cầu máy tính phải là thiết bị điện tử. Nhưng những cỗ máy tương tự có thể được làm từ các chất nền (vật liệu) khác, chẳng hạn như nước

image 59 - quochung.cyou PTIT

MONIAC, viết tắt của Monetary National Income Analogue Computer được chế tạo vào năm 1949, MONIAC là một máy tính được sử dụng để mô phỏng dòng tiền chạy qua nền kinh tế và để minh họa các tác động của các can thiệp kinh tế lên một mô hình kinh tế.

Một số người lập luận rằng máy tính phải là kỹ thuật số (digital), trái ngược với tương tự (analog). Một cỗ máy kỹ thuật số là cỗ máy biểu diễn dữ liệu bằng các chữ số, các tập hợp ký hiệu rời rạc như các chữ số nhị phân 0 và 1. Ngược lại, một cỗ máy tương tự có một tập hợp vô hạn, liên tục các trạng thái có thể xảy ra, chẳng hạn như lượng nước trong các bể của MONIAC, khiến MONIAC trở thành một cỗ máy tương tự.

Trong lý thuyết tính toán, chúng ta có Luận thuyết Church (Church’s thesis), một định nghĩa khắt khe hơn về máy tính mà hầu hết các nhà khoa học máy tính hiện đại đều đồng ý. Nó có thể được diễn giải như sau:

Một máy tính là một cỗ máy có thể mô phỏng bất kỳ cỗ máy nào khác, với điều kiện được cung cấp bao nhiêu bộ nhớ tùy ý.

Hiện tại các máy tính rõ ràng tồn tại để có thể thực hiện những việc sau, vì vậy một máy tính theo luận thuyết Church cũng phải có khả năng thực hiện các tác vụ này:

  • Đọc, ghi và xử lý dữ liệu.
  • Đọc, ghi và thực thi chương trình.
  • Cộng (và do đó thực hiện các phép tính số học).
  • Nhảy (lệnh goto).
  • Rẽ nhánh (lệnh if).

Trước cuộc cách mạng công nghiệp

image 60 - quochung.cyou PTIT

Vào khoảng những năm 40000 TCN,người ta tìm thấy xương Lebombo, một mảnh xương với các khía khắc có thể đã được sử dụng như một chiếc gậy tính. Một vạch đại diện cho một vật thể vật lý, có thể trong thời kì này, nó dùng cho đếm động vật, các mặt hàng thực phẩm hoặc các ngày để tính thời gian cho một cuộc săn bắn nào đó.

Xương Lebombo là một ví dụ về biểu diễn dữ liệu. Nó có thể đã được sử dụng cho một hình thức tính toán đơn giản như cộng thêm một vào tổng mỗi khi một dấu vết mới được tạo ra.

Khoảng những năm 4000 TCN, các thành phố đầu tiên bắt đầu phát triển. Các thành phố đòi hỏi những hành vi/tổ chức ở quy mô lớn hơn, ví dụ như theo dõi việc giao thương và thuế. Vào năm 3000 TCN, văn hóa thành phố Sumer ở Lưỡng Hà (Iraq ngày nay) đã phát triển hệ thống chữ viết đầu tiên, và đến năm 2500 TCN, họ đã sở hữu chiếc máy tính toán đầu tiên: bàn tính (abacus)

image 61 - quochung.cyou PTIT

Bàn tính đang biểu diễn 070710678

Trong cách sử dụng thông thường, trạng thái của bàn tính biểu diễn số (thập phân, tự nhiên).

Có chín cột, mỗi cột đại diện cho một trong các chữ số trong số này. Mỗi cột được chia thành một hộp dưới chứa năm hạt và một hộp trên chứa hai hạt. Vị trí mặc định của các hạt ở hộp dưới là ở dưới, và vị trí mặc định của các hạt ở hộp trên là ở trên.

Ở trạng thái này, một cột biểu thị chữ số 0. Mỗi hạt được đẩy từ dưới lên trên của hộp dưới có giá trị là 1. Mỗi hạt được đẩy từ trên xuống dưới của hộp trên có giá trị là 5.

  • Cột 1: 0
  • Cột 2: 1 nút xuống ở trên (+5) , 2 nút dưới đẩy lên (+2 ) = 7

Để cộng 1 vào một số trên bàn tính (tức là tăng nó lên), bạn nâng một hạt từ hộp dưới của cột ngoài cùng bên phải. Nếu tất cả năm hạt trong hộp dưới của một cột đã được nâng lên, bạn đẩy tất cả chúng xuống lại và thay thế chúng bằng cách hạ một trong các hạt ở hộp trên trong cùng cột đó. Nếu cả hai hạt trên đều đã hạ xuống, bạn đẩy chúng ngược lên và thay thế chúng bằng cách nâng một hạt từ hộp dưới ở cột bên trái của nó. Việc chuyển dữ liệu từ một cột sang cột bên trái của nó được gọi là phép toán nhớ. (Giống như việc xét chữ số của 1 số từ phải sang trái, thì mỗi hàng đơn vị ta x 10 vậy)

Để cộng hai số, a + b, trước tiên bạn thiết lập bàn tính để biểu diễn các chữ số của a. Sau đó, bạn thực hiện b lần tăng như trên. Trạng thái của bàn tính khi đó biểu diễn kết quả.

Kiểu tính toán này, nơi số thứ nhất được “nạp vào” thiết bị và số thứ hai được “cộng thêm vào” đó, để lại kết quả cuối cùng là trạng thái của hệ thống, được gọi là kiến trúc tích lũy (accumulator architecture), và nó vẫn còn được sử dụng phổ biến ngày nay. Nó “tích lũy” kết quả của một chuỗi các phép tính; ví dụ, chúng ta có thể cộng một danh sách gồm nhiều số bằng cách cộng lần lượt từng số vào trạng thái và xem tổng tích lũy mới nhất sau mỗi lần cộng.

Khoảng năm 100 TCN, xuất hiện cơ chế Antikythera. Nó được tìm thấy vào năm 1901 trong một xác tàu đắm ở Địa Trung Hải; con tàu bị đắm dường như đang trên đường từ Hy Lạp đến La Mã, mang theo cơ chế này để bán hoặc làm vật phẩm cống nạp.

image 62 - quochung.cyou PTIT

Chạy bằng bánh răng cơ khí, dùng để dự đoán các sự kiện thiên văn, bao gồm vị trí của năm hành tinh, các pha của mặt trăng, thời điểm nhật thực/nguyệt thực và thời gian diễn ra Thế vận hội Olympic. Nó bao gồm 37 bánh răng bằng đồng, và người dùng sẽ quay một tay cầm để mô phỏng diễn biến tương lai của các trạng thái thiên văn. Các kết quả được hiển thị trên các mặt đồng hồ, được tính toán dựa trên tỷ số truyền của các bánh răng cơ khí.

image 63 - quochung.cyou PTIT

Máy đo quãng đường (Odometers) là những cỗ máy đo khoảng cách tầm xa mà người Hy Lạp và La Mã đã sử dụng để khảo sát và lập bản đồ đế chế của họ.

Loại máy đo quãng đường này hoạt động như sau: chúng phát ra tiếng “cạch” mỗi khi được đẩy đi một khoảng cách nhất định. Chiếc máy đo này được kéo bởi một con ngựa, giống như một cỗ xe. Có một số viên bi kim loại được lưu trữ trong các hốc của một bánh răng gỗ hình tròn. Một trong các bánh xe của xe có gắn một cái chốt sao cho cứ sau mỗi vòng quay, nó lại chạm và làm xoay bánh răng một góc cố định nhỏ. Một cái lỗ có kích thước bằng viên bi nằm dưới một vị trí của bánh răng cho phép một viên bi phía trên nó rơi ra khỏi hốc và rơi vào một hộp thu thập bên dưới. Tổng quãng đường đã đi được ghi lại bằng số lượng viên bi trong hộp đếm vào cuối chuyến đi.

Những cỗ máy này có phải là máy tính không? Rõ ràng có các khái niệm về dữ liệu được sử dụng để đại diện cho các vật thể trong thế giới, cũng như các hình thức tự động hóa và tính toán. Nhưng giống như MONIAC, mỗi cỗ máy chỉ làm được một việc duy nhất: dự đoán nhật thực hoặc đo khoảng cách. Theo luận thuyết Church, ta thấy rằng các cỗ máy này không thể lập trình lại để làm 1 việc khác.

image 64 - quochung.cyou PTIT

Trong nhiều năm tiếp theo, xuất hiện nhiều “máy móc” với ý tưởng phát triển từ các ý tưởng trên. Ví dụ Codex Madrid của Davinci, dùng để tính toán qua hệ thống bánh răng. Tỷ số truyền giữa mỗi cặp cột là 1:10, vì vậy bánh xe của mỗi cột sẽ quay với tốc độ bằng 1/10 so với cột bên phải của nó. Có 13 bánh xe, mỗi bánh xe đại diện cho các cột của một số thập phân

image 65 - quochung.cyou PTIT

Cách mạng công nghiệp

Máy Phân tích đã được thiết kế như một chiếc máy lập trình được, có mục đích hoàn toàn tổng quát (general-purpose).

image 67 - quochung.cyou PTIT

Để đạt được tính tổng quát này, Máy Phân tích cung cấp một loạt các phép toán số học và các hoạt động khác dưới dạng các bộ phận máy đơn giản, cùng với một bộ nhớ để lưu trữ dữ liệu và khả năng đọc các chương trình từ thẻ đục lỗ.

image 69 - quochung.cyou PTIT

Các chương trình này quy định một trình tự các thao tác đọc/ghi bộ nhớ và các phép toán số học, đồng thời cho phép rẽ nhánh tùy thuộc vào trạng thái của phép tính, tức là một câu lệnh if.

Cấu trúc bộ nhớ và lệnh (Core and Memory)

Phần lõi của Máy Phân tích chứa nhiều máy đơn giản độc lập, mỗi máy thực hiện một chức năng nhất định, chẳng hạn như cộng các số và kiểm tra xem một số có bằng một số khác hay không.

Máy Phân tích đã giới thiệu khái niệm hiện đại về bộ nhớ máy tính. Mỗi máy có thể giữ một con số khác nhau. Mỗi máy sẽ được gán một mã định danh số hoặc “địa chỉ” (address) để chỉ định chính xác máy nào cần đọc hoặc ghi.

Một chuỗi các chỉ thị (instructions) sẽ được mã hóa dưới dạng nhị phân và đục lỗ trên băng giấy. Mỗi chỉ thị sẽ ra lệnh cho cỗ máy kích hoạt một trong các máy đơn giản. Thông thường, sau mỗi chỉ thị, cỗ máy sẽ đẩy băng giấy đục lỗ theo từng dòng để tải chỉ thị tiếp theo. Tuy nhiên, cỗ máy cũng có khả năng kiểm tra kết quả của máy đơn giản vừa chạy và tùy thuộc vào giá trị đó, nó có thể nhảy đến một dòng khác trên giấy. Điều này mang lại cho các chương trình khả năng thay đổi hành vi để phản hồi lại các kết quả trung gian.

Một chương trình cũng có thể được tạo ra để chạy mãi mãi bằng cách dán phần cuối của băng giấy đục lỗ vào phần đầu của nó, tạo thành một vòng lặp vật lý, như trong máy băng giấy.

image 70 - quochung.cyou PTIT

Máy Phân tích có phải là máy tính Church?

Thiết kế của nó chứa đựng tất cả các đặc điểm cơ bản của một máy tính hiện đại: CPU, bộ nhớ, bus, các thanh ghi, bộ điều khiển và đơn vị số học. Nó có thể đọc, ghi và xử lý dữ liệu. Nó có thể làm toán. Không giống như các máy tính thuần túy trước đó, nó có thể nhảy (goto) và rẽ nhánh (if), chuyển đến các chỉ thị khác nhau trong chương trình tùy theo trạng thái tính toán.

Cơ – Điện

Trước khi có máy tính hiện đại, thế giới trải qua một giai đoạn “lai” gọi là Cơ – Điện (Electromechanical). Thay vì dùng bánh răng thuần túy, người ta dùng điện để điều khiển các chuyển động cơ học.

Rơ-le là linh kiện then chốt của thời kỳ này. Bạn có thể hiểu đơn giản nó là một công tắc điện được đóng/ngắt bằng một tín hiệu điện khác, thay vì dùng tay người.

  • Cấu tạo và hoạt động: Nó gồm một cuộn dây đồng (solenoid) quấn quanh một lõi sắt. Khi có dòng điện chạy qua cuộn dây, nó tạo ra từ trường biến lõi sắt thành nam châm điện. Lực từ này sẽ hút một thanh sắt (gọi là phần ứng – armature) di chuyển vật lý để chạm vào tiếp điểm, giúp đóng hoặc ngắt một mạch điện thứ hai.
  • Tại sao nó quan trọng? Chỉ cần một dòng điện nhỏ, yếu có thể điều khiển một mạch điện khác mang năng lượng khổng lồ (như khởi động động cơ xe tải).
    • Tính logic: Nếu kết hợp nhiều rơ-le, ta có thể tạo ra các phép tính logic (VÍ DỤ: “Nếu rơ-le A đóng VÀ rơ-le B đóng thì đèn mới sáng”). Đây chính là nền tảng của tư duy máy tính.
image 72 - quochung.cyou PTIT

Hiến pháp Hoa Kỳ yêu cầu việc điều tra dân số phải được thực hiện và xử lý sau mỗi 10 năm, và đến năm 1890, dân số đã tăng đến mức việc xử lý các số liệu thống kê bằng con người là không thể. Herman Hollerith đã thiết kế một chiếc máy để tự động hóa việc xử lý dữ liệu và sử dụng nó thành công trong kỳ điều tra dân số năm 1890 để thực hiện phân tích dữ liệu lớn trên thông tin của 62 triệu công dân. Dữ liệu của mỗi công dân được chuyển từ mẫu đơn điều tra viết tay sang một thẻ đục lỗ bởi con người. Mỗi câu hỏi trong cuộc điều tra dân số là dạng trắc nghiệm, và được mã hóa trên thẻ đục lỗ bằng cách đục bỏ một trong số các lựa chọn.

image 71 - quochung.cyou PTIT

Các xấp thẻ có thể được đọc vào máy, máy sẽ kiểm tra sự hiện diện hay vắng mặt của các đặc điểm nhất định hoặc sự kết hợp của các đặc điểm. Điều này có nghĩa là chiếc máy này có khả năng tương đương với các truy vấn SQL hiện đại, bao gồm SELECT, WHERE, GROUP BY, và ORDER BY.

Cuộc chiến mật mã (Thế chiến II)

Thế chiến II là “lò luyện” thúc đẩy máy tính phát triển vượt bậc nhờ nhu cầu giải mã:

  • Máy Enigma (Đức): Một hệ thống các bánh xe xoay (rotors) làm thay đổi chữ cái theo quy luật điện học cực kỳ phức tạp. VD: tự động chuyển bất kì số nào thêm 3 đơn vị chẳng hạn, vậy nên mật báo truyền tin là “123” sẽ bị chuyển thành “456”, một đoạn văn bản sẽ bị mã hóa thành một dạng khác không dịch được
  • Máy Bomba (Ba Lan) và Bombe (Anh): Các cỗ máy cơ-điện dùng để “quét” hàng triệu khả năng của Enigma. Alan Turing (Anh) đã cải tiến máy này để bẻ khóa mật mã hải quân Đức, giúp phe Đồng minh xoay chuyển cục diện.
  • Zuse Z3 (Đức): Cỗ máy của Konrad Zuse (1941) sử dụng tới 2.000 rơ-le. Đây được coi là máy tính lập trình được đầu tiên, dù nó chạy rất chậm (10 lệnh/giây).

Khi rơ-le cơ học lộ khuyết điểm (chậm, hay bị kẹt, phát ra tiếng ồn), Bóng chân không (Vacuum Tubes/Valves) ra đời (1904).

image 73 - quochung.cyou PTIT

Hãy tưởng tượng nó như một cái bóng đèn nhưng có thêm một “lưới” điều khiển ở giữa.

  • Cơ chế: Khi sợi đốt nóng lên, các electron bị đẩy ra khỏi cực âm (cathode) và bay qua chân không để đến cực dương (anode), tạo thành dòng điện.
  • Tác dụng: Bằng cách thay đổi điện áp ở lưới điều khiển, ta có thể cho phép dòng điện đi qua hoặc chặn lại.
  • Ưu điểm vượt trội:không có bộ phận chuyển động cơ học (không có thanh sắt nào phải nhảy lên xuống như rơ-le), bóng chân không có thể đóng ngắt hàng triệu lần mỗi giây. Điều này giúp máy tính chuyển từ tốc độ “rùa bò” của rơ-le sang tốc độ điện tử cực nhanh.

Hoàn thành năm 1945, ENIAC là máy tính điện tử đa năng bằng bóng chân không.

image 74 - quochung.cyou PTIT
  • Lập trình bằng tay: Để “lập trình”, các nữ toán viên phải trực tiếp cắm các dây cáp vào bảng điều khiển

Máy ảo (Virtual Machine) đầu tiên: Nhóm lập trình viên gồm 6 phụ nữ (như Betty Jean Jennings, Frances Bilas…) đã tạo ra một bước ngoặt: Thay vì rút dây cho mỗi bài toán, họ thiết lập một cấu hình dây cố định cho phép máy đọc các lệnh từ các bảng công tắc.

Lúc này, các công tắc đóng vai trò là “phần mềm” sơ khai, điều khiển lớp “phần cứng” bên dưới. Đây chính là khái niệm máy ảo đầu tiên trong lịch sử.

Kiến trúc von Neumann

Được chứng minh lần đầu bởi máy Manchester Baby (1948), đây là kiến trúc mà mọi máy tính và smartphone ngày nay vẫn dùng.

image 75 - quochung.cyou PTIT
  • Nguyên lý Stored-program: Cả chương trình và dữ liệu đều được lưu trữ chung trong cùng một bộ nhớ.
  • Phát kiến:
    • 1. Tốc độ: Thay đổi chương trình chỉ đơn giản là nạp một dãy số mới vào bộ nhớ, không cần động vào phần cứng.
    • 2. Tự sửa đổi (Self-modifying code): Vì chương trình cũng chỉ là dữ liệu trong bộ nhớ, máy tính có thể tự viết ra các lệnh mới hoặc tự thay đổi mã nguồn của chính nó khi đang chạy. Đây là nền tảng tối thượng cho trí tuệ nhân tạo và các hệ điều hành phức tạp.

Định luật Moore và máy tính hiện đại

Năm 1947, Bóng bán dẫn (Transistor) ra đời, đánh dấu sự kết thúc của những căn phòng đầy bóng chân không nóng bức.

Sự vượt trội của transistor đến từ bản chất vật lý của trạng thái rắn so với môi trường chân không:

  • Không cần năng lượng kích hoạt: Bóng chân không cần dòng điện để nung nóng sợi đốt liên tục (giống bóng đèn dây tóc), gây lãng phí năng lượng cực lớn dưới dạng nhiệt. Transistor hoạt động bằng điện trường, không cần nung nóng, tiêu thụ năng lượng ít hơn hàng nghìn lần.
  • Kích thước và khả năng tích hợp: Vì hoạt động trong chất rắn, transistor có thể được thu nhỏ xuống mức nanomet. Bóng chân không cần khoảng không gian vật lý đủ lớn để các electron bay qua và vỏ thủy tinh để duy trì chân không, nên không thể thu nhỏ.
  • Độ bền vật lý: Bóng chân không có sợi đốt sẽ bị “cháy” sau một thời gian sử dụng và vỏ thủy tinh dễ vỡ. Transistor là một khối chất rắn đồng nhất, không có bộ phận tiêu hao hay chuyển động, nên tuổi thọ gần như vĩnh cửu trong điều kiện bình thường.
  • Tốc độ chuyển mạch (Switching Speed): Việc điều khiển dòng điện bằng điện trường trong chất bán dẫn nhanh hơn rất nhiều so với việc điều khiển dòng electron bay trong chân không.

Những năm 1960 và các Bóng bán dẫn lớn (The 1960s and Big Transistors)

Các “máy tính mini” (minicomputers) chạy bóng bán dẫn của những năm 1960 không sử dụng vi mạch (microchips), mà thay vào đó được chế tạo từ các loại bóng bán dẫn “lớn”, dài khoảng 1 cm.

image 77 - quochung.cyou PTIT

Các ứng dụng của máy tính bán dẫn trong những năm 1960 bao gồm việc vận hành ARPANET, tiền thân của internet dựa trên TCP/IP ngày nay, và việc Margaret Hamilton lập trình mã đổ bộ lên mặt trăng của tàu Apollo năm 1969 bằng ngôn ngữ hợp ngữ (assembly). Hình ảnh là bản in chương trình hợp ngữ hoàn chỉnh của bà cho tàu Apollo 11

image 78 - quochung.cyou PTIT

Công việc sau này thực sự là “khoa học tên lửa” (rocket science), và yêu cầu bà phải tạo ra lĩnh vực kỹ thuật phần mềm (software engineering) hiện đại trong khi tìm kiếm các phương pháp để làm cho đoạn mã cực kỳ quan trọng này trở nên chính xác hơn.

Vào năm 1965, Gordon Moore, CEO của Intel, đã đưa ra một quan sát mà từ đó được gọi là Định luật Moore. Định luật này nói rằng tốc độ của máy tính hoặc số lượng bóng bán dẫn trên mỗi đơn vị diện tích sẽ tăng gấp đôi sau mỗi 18 tháng hoặc 2 năm.

1970 và IC

Công nghệ IC cho phép các mạch điện dựa trên bóng bán dẫn được thu nhỏ lại, sao cho cùng một hệ thống dây điện từng lấp đầy một tủ rack những năm 1960 giờ đây có thể nằm gọn trên một “chip” silicon kích thước bằng móng tay. Từ góc nhìn kiến trúc, các con chip không có gì quá kỳ lạ, nếu bạn lấy sơ đồ đi dây của một chiếc máy bóng đèn chân không những năm 1940 và thu nhỏ nó lại, bạn sẽ có một con chip.

Nếu nhìn chip qua kính hiển vi, bạn sẽ thấy các mô hình đi dây tương tự như dây điện ở mặt sau của các tủ rack những năm 1940, 1950 hoặc 1960. Chip silicon sau đó được “đóng gói” bên trong một khối nhựa thường có màu đen lớn hơn, với các chân kim loại lớn kết nối các đầu vào và đầu ra tinh vi của chip với thế giới bên ngoài, thường là một bảng mạch in.

image 76 - quochung.cyou PTIT

1980

Những năm 1980 là thời hoàng kim của kiến trúc máy tính: lần đầu tiên, máy tính điện tử trở nên đủ rẻ và nhỏ để được sản xuất hàng loạt và được những người bình thường mua về sử dụng tại nhà.

image 79 - quochung.cyou PTIT

Chiếc IBM 5150 PC ra mắt vào năm 1981, dựa trên chip Intel 8088. IBM và các công ty khác đã bán chiếc máy này cũng như các máy PC khác trong suốt những năm 1980 để sử dụng trong các văn phòng kinh doanh.

Khái niệm PC (Personal Computer) là thái cực đối lập với thị trường máy tính gia đình đa dạng và thiên về kiến trúc vì hai lý do.

  • Thứ nhất, nó thực thi một kiến trúc chuẩn hóa trên các thành phần máy tính để nhiều nhà sản xuất có thể sản xuất chúng tương thích với nhau.
  • Thứ hai, nó bao bọc tất cả phần cứng dưới một hệ điều hành nghiêm ngặt, kiểm soát mọi quyền truy cập vào phần cứng thông qua một giao diện tiêu chuẩn. IBM có thể sử dụng ảnh hưởng thị trường của mình để áp đặt các tiêu chuẩn lên linh kiện, nhờ đó họ có thể mua chúng từ những nhà cung cấp rẻ nhất và kiếm tiền bằng cách đóng dấu thương hiệu của mình lên những chiếc PC đã lắp ráp.

Để phản ứng lại các hệ điều hành độc quyền đang được cài đặt trên PC và các máy tính lớn hơn, dự án GNU và phong trào Phần mềm Tự do đã được Richard Stallman tạo ra trong thập kỷ này, điều này sau đó đã dẫn đến các hệ thống và triết lý dựa trên Linux mà chúng ta sử dụng ngày nay.

1990

Kiến trúc máy tính bị thống trị bởi kiến trúc tiêu chuẩn của máy tính cá nhân (PC), vốn đã được sử dụng trong tin học văn phòng những năm 1980 nhưng giờ đây được các tập đoàn PC đẩy mạnh khắp nơi, kể cả trong gia đình và trường học. Các hệ điều hành mã nguồn đóng được bán kèm như một phần của gói PC. Trong thời gian này, các trường học đã ngừng dạy khoa học máy tính và tập trung dạy cách sử dụng phần mềm văn phòng thương mại.

Chuyển từ việc coi đối tượng người dùng là lập trình viên và thành viên cộng đồng công nghệ sang coi người dùng là khách hàng và người tiêu dùng các sản phẩm phần mềm, chẳng hạn như trình xử lý văn bản và bảng tính (VD: Word, Excel, …)

Như định luật Moore đã dự đoán một cách đáng tin cậy, tốc độ bộ vi xử lý tăng gấp đôi sau mỗi 18 tháng. World Wide Web chính thức hoạt động tại CERN vào năm 1990 và phát triển phổ biến, dẫn đến cơn sốt đầu tư dot-com vào cuối thập kỷ. Khi có nhiều hacker và cuối cùng là người tiêu dùng tham gia vào web, các thiết kế máy tính máy chủ gắn trên giá đỡ (rack-mounted server) chuyên dụng đã trở nên phổ biến, bắt đầu với Compaq ProLiant vào năm 1993. Giống như Manchester Baby và các máy tính mini những năm 1960, chúng được thiết kế để xếp chồng trong các đơn vị giá đỡ 19 inch, nhưng hoạt động liên tục với độ tin cậy cao. -> xuất hiện định nghĩa về server

image 80 - quochung.cyou PTIT

Những năm 2000, 2010 và Sự kết thúc của Định luật Moore

Kiến trúc PC gồm các linh kiện hàng hóa kết hợp với hệ điều hành vẫn tiếp tục duy trì trong suốt những năm 2000. Định luật Moore, và hệ quả là việc lắp ráp hoặc mua một chiếc máy tính mới có tốc độ gấp đôi sau mỗi vài năm, vẫn tiếp diễn. Các máy móc sử dụng cùng một thiết kế máy tính PC cơ bản, với các giao diện và thành phần khác nhau được nâng cấp về tốc độ. Tốc độ internet cũng tăng lên, cho phép truyền phát video cũng như chuyển văn bản và hình ảnh.

Trong những năm 1990 và 2000, giả định rằng tốc độ xung nhịp của các bộ vi xử lý sẽ tăng gấp đôi sau mỗi vài năm, và thực tế là như vậy. Định luật Moore trở thành một lời tiên tri tự ứng nghiệm khi các nhà sản xuất chip ở Thung lũng Silicon sử dụng nó như một mục tiêu cần đạt được.

Tuy nhiên, tất cả đã sụp đổ vào những năm 2010. Công nghệ sản xuất bóng bán dẫn thực sự vẫn tiếp tục tăng gấp đôi số lượng bóng bán dẫn trên mỗi đơn vị diện tích, nhưng tốc độ xung nhịp đã đạt mức tối đa vào năm 2010, ở khoảng 3.5 GHz.

Đột nhiên, các bộ vi xử lý không còn nhanh hơn nữa. Điều này là do các định luật vật lý cơ bản xung quanh tốc độ tính toán và nhiệt lượng. Trong thời kỳ định luật Moore, nhiệt độ của các bộ vi xử lý cũng tăng lên cùng với tốc độ; cần có các quạt lớn hơn, mạnh hơn và các hệ thống làm mát khác như làm mát bằng chất lỏng. Các bóng bán dẫn nhỏ đi, nhưng các cánh quạt lại to ra. Nếu xu hướng này tiếp tục qua những năm 2010, giờ đây chúng ta sẽ có những bộ vi xử lý nóng hơn cả bề mặt mặt trời.

Một khái niệm liên quan chặt chẽ là mức tiêu thụ điện năng. Khi các con chip tỏa nhiều nhiệt hơn, chúng tiêu thụ nhiều điện hơn, và thập kỷ này cũng chứng kiến sự khởi đầu của một nỗ lực hướng tới tính toán năng lượng thấp, di động hơn, đặc biệt là dưới dạng điện thoại thông minh.

Trong khi hai thập kỷ trước đó chứng kiến kiến trúc máy tính trì trệ như một lĩnh vực nghiên cứu, chỉ dựa vào những tiến bộ trong công nghệ chế tạo để tạo ra các lợi ích định kỳ, thì giờ đây lĩnh vực này lại mở rộng trở lại cho những ý tưởng hoàn toàn mới.

Chúng ta không thể làm máy tính nhanh hơn thông qua dạng “tốc độ” của định luật Moore, nhưng chúng ta vẫn có thể nhồi nhét ngày càng nhiều bóng bán dẫn lên chip với dạng “mật độ” của nó. Giờ đây, chúng ta có thể xem xét việc làm cho mọi thứ trở nên song song (parallel), thực hiện nhiều thao tác cùng một lúc thay vì từng cái một.

Trong khi vẫn còn nhiều kiến trúc song song cần được khám phá, những năm 2010 đã chứng kiến ba loại kiến trúc song song chính thành công trong thế giới thực.

  1. Đa nhân (Multicore): Đây là các con chip được sản xuất để chứa nhiều bản sao của một thiết kế CPU. Thập kỷ này bắt đầu với các hệ thống lõi kép (duo-core) và tiến triển qua lõi tứ (quad), tám (eight) và thậm chí nhiều lõi hơn.
  2. Điện toán cụm (Cluster computing): Một dạng song song hóa trong đó nhiều máy đơn nhân hoặc đa nhân thông thường được liên kết yếu với nhau. Công việc tính toán được chia thành nhiều phần độc lập có thể gán cho mỗi máy. Phương pháp này đặc biệt hữu ích cho các tác vụ “dữ liệu lớn” theo mô hình map-reduce.
  3. Xử lý đồ họa (GPU): Sự phát triển của các card đồ họa (đơn vị xử lý đồ họa – GPU) thành các thiết bị tính toán song song đa năng. Khái niệm này hiện đang liên tục phát triển thành nhiều kiến trúc mới lạ, chẳng hạn như các chip được quảng cáo phục vụ cho A.I (NPU/TPU) gần đây được tìm thấy trên điện thoại di động.

2020, Cloud/IoT

  • Internet Vạn vật (IoT): Các thiết bị ngày càng nhỏ và rẻ sẽ được nhúng vào ngày càng nhiều vật thể trong thế giới thực. Từ “thành phố thông minh” giám sát giao thông đến “nhà máy thông minh” theo dõi từng món hàng. Tủ lạnh của bạn sẽ tự nhận biết khi nào sắp hết pho mát và tự động đặt hàng ở siêu thị.
  • Điện toán Đám mây (Cloud Computing): Ngược lại với xu hướng trên, các thiết bị IoT năng lượng thấp sẽ không tính toán nhiều mà chủ yếu để thu thập dữ liệu. Dữ liệu này sau đó sẽ được xử lý ở quy mô khổng lồ trong các trung tâm tính toán chuyên dụng: những tòa nhà có kích thước bằng kho hàng chứa đầy năng lượng tính toán.

Xu hướng cho thấy rõ các tài nguyên nghiên cứu đổ vào để tạo ra các sản phẩm nhỏ nhất, dùng ít năng lượng nhất, hiệu quả hơn (như điện thoại thông minh, tivi thông minh, tủ lạnh thông minh, máy giặt thông minh, ….) , hoặc ngược hẳn lại, là tạo ra những thứ mạnh nhất, to nhất (các hệ thống datacenter, server khổng lồ), và hiếm có đầu tư cho vùng ở giữa.

Khái niệm tính toán hiện đại đã được Church định nghĩa. Các máy điện tử thương mại của những năm 1950, bắt đầu với UNIVAC, qua các máy tính mini những năm 1960 và vi mạch những năm 1970 cho đến ngày nay dường như có thể được nhận diện rõ ràng là máy tính. Nhưng liệu có bất cứ thứ gì trước đó nên được công nhận là “chiếc máy tính đầu tiên”?

  • Manchester Baby là một máy tính Church nếu chấp nhận rằng nó có thể được cấp bộ nhớ vô hạn, dù cách làm không rõ ràng.
  • ENIAC có tiềm năng là máy tính Church nếu được lập trình theo cách máy ảo (VM), nhưng nó vẫn là kiến trúc Harvard.
  • Zuse Z3, Colossus, hay Máy Phân tích của Babbage đều có thể đạt được trạng thái này về mặt lý thuyết.
  • IBM đã thực hiện phân tích dữ liệu lớn từ những năm 1890, nhưng phân tích dữ liệu không phải là tính toán Church tổng quát trừ khi bạn tìm ra cách biến mọi vấn đề thành một truy vấn SQL.

Có lẽ con người đã tính toán từ năm 40.000 TCN với bàn tính, xương, đá và các con số trong đầu. Tất cả những thứ trên về mặt lý thuyết đều là máy tính Church vì chúng có thể mô phỏng bất kỳ cỗ máy nào nếu được lập trình theo một cách nhất định. Vì vậy, có lẽ chúng ta đã luôn có máy tính — và Church chỉ là người đầu tiên nhận ra chúng.

Tham khảo:

  • Computer Architecture (Charles Fox)
  • Kiến trúc máy tính (Wikipedia)
  • Parallel and High Performance Computing (Yuliana Zamora, Robert Robey)

Chuẩn hoá Database vs Đảo chuẩn hoá Database (Database Normalization vs. Denormalization), case study của Instagram

Chuẩn hoá Database là gì

  • Chuẩn hoá database (Database normalization) là một cách thức cấu trúc các dữ liệu trong một cơ sở dữ liệu quan hệ theo một dạng chuẩn. Đơn giản thì, ta sẽ kết nối các thông tin giữa các bảng khác nhau bằng 1 key hay 1 id gì đó định danh 1 hàng.
  • Việc chuẩn hoá database giúp đảm bảo dữ liệu sẽ “chuẩn hơn”, giảm bớt các khả năng xảy ra một số hiện tượng dưới đây:

Một số vấn đề mà chuẩn hoá database giải quyết

  • Insertion anomaly (Khi thêm dữ liệu): Xảy ra khi một dữ liệu được thêm vào bảng nhưng không theo cấu trúc của bảng. Ví dụ, ta có một cột trong bảng người dùng là address, chứa địa chỉ người dùng. Tuy nhiên do không có ràng buộc gì cả, thì có thể có những người dùng địa chỉ là “xã, tỉnh, quốc gia”, có người lại là “số nhà, xã, tỉnh, quốc gia”, do dữ liệu ở 1 cột chỉ là 1 dạng text. 
  • Update anomaly (Khi cập nhật) xảy ra khi dữ liệu không được cập nhật ở mọi vị trí. Ví dụ, ta có 2 bảng là thông tin sinh viên và thông tin công dân. Cả 2 đều chỉ về 1 đối tượng, tuy nhiên do có 2 cột địa chỉ ở cả 2, thì khi cập nhật 1 cái, có thể địa chỉ của cái còn lại cũng nên được đồng bộ.
  • Deletion anomaly (Khi xoá): Do dữ liệu “địa chỉ” nếu nằm ở nhiều bảng khác nhau dù chung mục đích, khiến nếu muốn xoá một dữ liệu đi cần xoá ở nhiều vị trí hơn.

Ví dụ về chuẩn hoá Database

  • Giả sử ta có bảng sau để chứa các Assistant Principal, sẽ dạy các học sinh nào, lớp nào
image 25 - quochung.cyou PTIT
  • Ta thấy, ta đang lưu danh sách học sinh theo kiểu cách nhau bằng dấu phẩy, và tương tự với student id.
  • Có một số vấn đề có thể nhận thấy. Việc lưu như vậy khiến ta phải scale chiều dài dữ liệu cột Students theo số lượng sinh viên, và ví dụ, ta scale nó là một dung lượng nào đó là 10000 kí tự chẳng hạn, mà nhiều dòng lại không dùng tới 10000 kí tự => điều này vô hình chung làm dữ liệu của hàng bị phình to, chỗ nhiều chỗ ít, mà lại tốn tài nguyên để scale toàn bộ hàng có dữ liệu là 10000.
  • Ở mức chuẩn hoá 1NF, ta có thể tách bảng thành như sau
image 26 - quochung.cyou PTIT
  • Ta vẫn lưu được các dữ liệu cần thiết, và trong 1 ô đảm bảo không có lưu như vậy nữa
  • Tuy nhiên, ta nhận thấy, có nhiều hàng có dữ liệu trùng nhau, vd như Jack White, hay id của các Assistant Principal
  • Với ở dạng này, ta nhận thấy, nếu bảng có thêm nhiều trường hơn, to hơn nữa. Thì ta sẽ có rất nhiều hàng trùng dữ liệu thừa thãi, kiểu đều chung Class, Class ID, Assistant Principal Id, …. rất nhiều trường khác, chỉ khác nhau đúng trường Student Name và Student ID
image 27 - quochung.cyou PTIT
image 28 - quochung.cyou PTIT
  • Lúc này, ta sang mức chuẩn hoá 2NF. Ta sẽ nhìn chung vẫn như cách 1NF là có nhiều hàng tách ra để hiển thị dữ liệu. Tuy nhiên, ta chỉ nhớ chúng theo ID, rồi sau đó từ ID đó ta có thể lấy dữ liệu ở bảng riêng biệt dành riêng cho việc lưu trữ Student => Mà không gộp nó vào 1 chỗ nữa
  • Lúc này dữ liệu trùng và bị lặp chỉ có cột id, và nó nhẹ hơn nhiều
image 29 - quochung.cyou PTIT
image 30 - quochung.cyou PTIT
image 31 - quochung.cyou PTIT
  • Ở mức chuẩn hoá 3NF tiếp theo, ta đưa mọi dữ liệu về riêng bảng của nó. VD ta có bảng riêng chỉ lưu class, bảng riêng chỉ lưu student, …

Ưu điểm chuẩn hoá

  • Chuẩn hoá database nhìn chung giúp ta giảm bớt sự trùng lặp dữ liệu, dữ liệu dư thừa, bằng việc sử dụng nhiều bảng riêng biệt để lưu các dữ liệu cần thiết. Giúp dữ liệu gọn nhẹ, dễ nhìn hơn.
  • Chuẩn hoá cũng giúp ta giảm thiểu việc có các ô dữ liệu trống (null), vì ví dụ: Nếu ta có 1 bảng chỉ là ConNguoi, có cả cột lương thưởng (cho người đi làm), và tiền đóng học (cho sinh viên). Vậy sinh viên thì không có lương thưởng, còn người đi làm không có ô tiền đóng học. Ta có thể tách riêng bảng ra cho mục đích đó.

Đảo chuẩn hoá Database và case study của Instagram

Nhược điểm của chuẩn hoá

  • Vì lúc này dữ liệu bị phân tán ra, ta thường phải viết các câu query phức tạp hơn, gộp nhiều bảng vào với nhau để thao tác dữ liệu, việc này tăng độ phức tạp của hệ thống, cũng như giảm tốc độ vì phải đọc nhiều bảng khác nhau.
image 33 - quochung.cyou PTIT
  • Ví dụ với dữ liệu trên, để lấy danh sách sinh viên, hoặc số lượng sinh viên 1 Assistant Principal quản lý, ta chỉ cần 1 cột trong bảng, rồi select 1 row là được
  • Còn nếu tách riêng ra, ta phải join cả bảng Student vào, rồi select filter theo assistant id, và để đếm số lượng thì phải dùng count
  • Việc này được gọi là đảo chuẩn hoá database, nó có thể làm dữ liệu bị dư thừa và tạo ra nhiều vấn đề mà chuẩn hoá giải quyết, nhưng bù lại giúp cải thiện hiệu năng hệ thống

Ví dụ của Instagram về chuẩn hoá database

image 34 - quochung.cyou PTIT
image 36 - quochung.cyou PTIT
  • Ta có bảng users là thông tin người dùng. 1 bảng posts là các bài viết của người dùng, và bảng likes là chứa thông tin về các lượt like của 1 bài viết, gồm ai là người like bài viết đó.
  • Lúc này, để lấy tổng số like của bài viết, ta thường phải gọi lệnh vào bảng likes dạng
SELECT
    posts.id AS post_id,
    posts.title AS post_title,
    COUNT(likes.id) AS total_likes
FROM
    posts
LEFT JOIN
    likes ON posts.id = likes.post_id
WHERE
    posts.id = 1
GROUP BY
    posts.id, posts.title;
  • cơ bản thì nó cần join vào bảng likes, rồi tìm các likes thuộc id bài viết và đếm. Điều này nhìn chung nếu 1 bài viết có 1 triệu likes, thì ta phải đếm 1 triệu hàng, và có hàng tá bài viết như thế, và rất nhiều người cùng vào like, xem bài viết, mỗi khi cần hiển thị tổng số like, ta phải chạy lại lệnh kia, thứ mà rất tốn thời gian.
image 37 - quochung.cyou PTIT
  • Lúc này ta có thể áp dụng đảo chuẩn hoá, bằng việc đơn giản là sẽ thêm 1 cột total_likes để lưu trữ tổng lượng like của bài viết, ta vẫn cần bảng likes để khi người ta mở xem ai là người likes thì nó sẽ lưu trữ thông tin.
  • Hiển nhiên, việc này sẽ khiến việc quản lý và code sẽ phức tạp hơn để vừa có cơ chế chuẩn hoá, mà lại đảo chuẩn hoá 1 thuộc tính, nhưng nó giúp cho query database nhanh hơn rất nhiều.

Tham khảo:

[Java Memory 2] Cách Garbage Collector Java giải phóng bộ nhớ (Stop The World, Reference Counting, Sweep, ..)

This entry is part 2 of 2 in the series Java Memory

Trở thành “mồi” của GC (Garbage Collector)

  • Ta đã biết, khi một đối tượng không còn tác dụng nữa, thì chúng sẽ bị GC dọn đi để tiết kiệm bộ nhớ, nhưng thế nào là “hết tác dụng” ?
  • Có thể tóm gọn bằng một câu cơ bản “Object tại heap sẽ không còn hữu dụng nếu chúng mất kết nối tới stack”
  • Object mất kết nối tới stack khi không còn một con trỏ nào chỉ tới chúng nữa cả, hãy xem thử đoạn code sau:
Object o = new Object();
System.out.println(o);
o = null; 
  • Ở dòng đầu tiên, ta tạo một đối tượng o, o lúc này thực chất đang chỉ tới giá trị thực sự của đối tượng ta vừa tạo ra trong heap
  • Khi in thử đối tượng này ra, ta có output theo mẫu sau
java.lang.Object@4617c264
  • Tiếp theo, ta cập nhật o thành null. Lúc này, đối tượng nằm ở heap không còn cái gì trỏ đến nó nữa, và không gì có thể truy cập lại nó nữa, và lúc này nó trở thành mồi của GC

Vấn đề không đơn giản

  • Ví dụ trên khá đơn giản do chỉ có 1 object, vấn đề sẽ phức tạp hơn khi ta tiếp cận với nhiều object liên kết với nhau 1 lúc
image 31 - quochung.cyou PTIT
  • Giả sử với 6 dòng lệnh sau, ta sẽ đi qua từng dòng một
image 32 - quochung.cyou PTIT
  • Sau 4 dòng đầu tiên, stack và heap của chúng ta có dạng như sau. Tại stack là các biến giữ con trỏ tới các giá trị thực trong heap.
image 33 - quochung.cyou PTIT

  • Sau dòng thứ 5, ta có hình như sau. Ta thấy, dù sau khi đã cập nhật p1 thành null với mong muốn GC sẽ dọn object này, nhưng thực tế, ta thấy ta vẫn có thể truy cập vào p1 bằng person.get(1) qua list persons, tức là ta vẫn có cách reach đến điểm này
image 34 - quochung.cyou PTIT

  • Chỉ sau khi set cả list thành null, ta mới mất hoàn toàn liên hệ với p1
  • => Không khó để đánh giá các phần tử nào sẽ được Garbage Collector dọn khi bạn đã hiểu về quan hệ bên trên. Dễ dàng thấy, sau 6 bước, p1 mất hoàn toàn liên hệ với stack và sẽ được dọn
  • Tuy nhiên, đó là ta nhìn thủ công, còn để garbage collector biết cái nào còn kết nối với stack sẽ tốn một lượng thời gian, và nó sẽ làm chậm hệ thống lại (chi tiết ở bên dưới). Có nhiều cách để làm điều này, và ta sẽ thảo luận ở bên dưới

Đánh dấu đệ quy

  • Ta sẽ thử đánh dấu các object còn live và object nào có thể bị dọn bởi GC. Dễ dàng, ta thêm 1 bit để dánh dấu xem chúng có còn kết nối với stack hay không. Khi tạo, ta sẽ để bit là 0, và khi ở giai đoạn đánh dấu, object là 0 sẽ bị xoá đi, còn nếu vẫn còn sử dụng, ta sẽ cập nhật nó thành 1
  • Tuy nhiên, heap và stack thay đổi liên tục. Cách đánh dấu được triển khai tuỳ thuộc vào phiên bản Java và GC bạn sử dụng, tuy nhiên, ta sẽ thử xem cách hệ thống đánh từ stack, hãy thử xem ví dụ sau
image 35 - quochung.cyou PTIT
  • Đầu tiên toàn bộ phần tử sẽ được để là 0
image 36 - quochung.cyou PTIT
  • Sau đó, toàn bộ các phần tử có kết nối trực tiếp tới stack được đánh thành 1
image 37 - quochung.cyou PTIT
  • Tuy nhiên, do p1 vẫn có thể truy cập từ danh sách persons, và hơn nữa, ta không thể cứ dọn những gì vẫn còn kết nối. Vì vậy ta cần một cách duyệt đơn giản, với điểm khởi đầu từ các điểm đang = 1, sau đó đi đến mọi quan hệ của nó và đánh chúng thành 1, rồi tiếp tục đệ quy.
  • Các thuật toán đánh dấu đóng vai trò quan trọng trong giai đoạn đánh dấu, đầu tiên thử đi qua cách stop-the-world

Kĩ thuật Stop-The-World – Dừng thế giới

image 38 - quochung.cyou PTIT
  • Với cách đánh dấu trên, ta nhận thấy. Nếu một phần tử được tạo trong quãng đánh dấu, nó sẽ không còn đúng nữa.
  • Vì vây, có một solution đơn giản là ta sẽ dừng mọi luồng khác và chỉ chạy luồng đánh dấu của cả chương trình, điều này sẽ rất ảnh hưởng đến hiệu năng. Ta sẽ thử xem các thuật toán tiếp theo.

Kĩ thuật Reference counting – Đếm liên hệ

image 39 - quochung.cyou PTIT

  • Một cách triển khai khác là đếm số lần một object được trỏ đến. Mỗi object sẽ chứa số lần object được trỏ như một thông số mà nó nắm giữ. Như vậy, GC chỉ việc quét qua và xoá mọi object có 0 lần bị nắm giữ. Cách này sẽ không cần stop-the-world như cách đánh số đệ quy nữa, vì khi object được tạo ra giữa chừng lúc đánh số lần trỏ thì nó vẫn đều là 1 rồi.
  • Tuy nhiên, nó có một điểm yếu là sẽ tạo ra island of isolation, hay các vùng, một tập các object tự trỏ nhau nhưng mà thực tế không có kết nối tới stack
image 40 - quochung.cyou PTIT

Giải phóng bộ nhớ

  • Cách thức làm sao để đánh dấu các object sẽ bị xoá sẽ được quyết định khác nhau bởi phiên bản Java và các kiểu GC khác nhau.
  • Giả sử ta đã đánh dấu được hết các object sẽ có thể bị xoá, tuy nhiên việc xoá chúng đi cũng không phải một quá trình đơn giản
  • Việc xoá object được gọi là sweeping by garbage collector, trong bài viết sẽ mention 3 cách thức sweeping khác nhau
    • Normal sweeping
    • Sweeping with compacting
    • Sweeping with copying

Normal sweeping

image 41 - quochung.cyou PTIT
  • Hình ảnh trên thể hiện các khối bộ nhớ trong ram, các vùng có dấu X là các object đã được đánh dấu và chuẩn bị bị xoá
image 42 - quochung.cyou PTIT
  • Sau khi xoá đi, vùng nhớ của ta có dạng như sau, dễ thấy, điều này dẫn tới một triệu chứng có tên Fragmentation

Fragmentation trong Java Garbage Collector

image 43 - quochung.cyou PTIT
  • Lúc này, vì các vùng trống nằm ở giữa các vùng bị chiếm dụng, nên ta chỉ có thể thêm các bộ nhớ nhỏ hơn hoặc bằng vùng vào các vùng trống. Điều này sẽ dẫn tới 1 vài vấn đề khi ta muốn cấp phát một bộ nhớ lớn hơn
image 44 - quochung.cyou PTIT
  • Giả dụ với trường hợp cấp phát một vùng nhớ lớn hơn các khe trống như ảnh trên, khi cấp phát vào, ta chỉ có thể xếp như sau:
image 45 - quochung.cyou PTIT
  • Dù tổng thể bộ nhớ còn trống ta vẫn đủ để xếp vùng nhớ, nhưng thực tế thì ta không có một vùng nhớ liên tiếp nào chứa đủ vùng nhớ mới này. Và việc này sẽ throw ra 1 runtime exception là OutOfMemoryError.

Ưu nhược điểm của Normal sweeping

  • Normal Sweeping là một kĩ thuật giải phóng vùng nhớ ngây thơ, khá tiện dụng và đơn giản. Tuy nhiên sẽ dẫn tới các vùng nhớ bị phân mảnh. Quá trình này phù hợp khi ta có nhiều bộ nhớ, và ta chỉ cần nhanh chóng dọn bộ nhớ đi. Khi mà lượng vùng nhớ còn trống nhỏ hơn, ta sẽ prefer các kĩ thuật khác

Sweeping with compacting

  • Sweeping with compacting là một quá trình 2 bước. Đầu tiên, chúng vẫn giải phóng bộ nhớ, nhưng sau đó ta sẽ thực hiện thêm 1 bước gọi là compacting (thu gọn), ta sẽ dời toàn bộ vùng nhớ về phía đầu để đảm bảo không có bất kì khoảng trống nào ở giữa
image 46 - quochung.cyou PTIT

Ưu nhược điểm Sweeping with compacting

  • Cách làm này giúp bộ nhớ không còn bị phân mảnh như thông thường, tuy nhiên việc di chuyển các vùng nhớ về đầu là một quá trình tốn kém, vì gần như với lượng vùng nhớ nhỏ và trải dài nhiều, ta sẽ phải copy và di chuyển khá nhiều trên vùng nhớ.

Sweeping with copying

image 50 - quochung.cyou PTIT
  • Ở cách làm này, ta sẽ cần 2 vùng nhớ khác nhau. Ta sẽ không trực tiếp xoá các vùng nhớ bị đánh dấu là xoá đi, mà ta sẽ copy các vùng nhớ k bị xoá vào vùng nhớ mới
image 51 - quochung.cyou PTIT
  • Sau đó mới thực hiện xoá toàn bộ vùng nhớ ở vùng nhớ cũ
image 52 - quochung.cyou PTIT

Ưu nhược điểm của Sweeping with copying

  • Dữ liệu không bị phân mảnh
  • Về hiệu năng thì nhanh hơn compacting, do không phải thực hiện nhiều công đoạn tính toán khi di chuyển, mà chỉ copy nhanh chóng sang vùng mới đang trống hoàn toàn (ở compacting ta ví dụ di chuyển vùng 30-50 sang vùng 0-10, đầu tiên ta phải di 30-40, sau đó lại di vùng 10-20 đi, …. và khó khăn hơn nhiều)
  • Tuy nhiên cần nhiều bộ nhớ hơn vào cùng 1 thời điểm, cần lượng bộ nhớ trữ còn lại đủ nhiều để di chuyển.

[Java Core] B2: Class và Object (Lớp và đối tượng)

This entry is part 2 of 8 in the series Java Core

Tổng quan về Class và Object

f0035 01 - quochung.cyou PTIT

Class và Object (Lớp và Đối tượng) là hai trong những khái niệm quan trọng nhất của ngôn ngữ lập trình hướng đối tượng (OOP).

Sự khác biệt chính giữa một Class và một Object trong Java là:

  • Class là một mô hình chi tiết để bạn sử dụng tạo ra các Object. Class định nghĩa tất cả các thuộc tính và các phương thức cần thiết của một Object.
  • Mỗi Object phải thuộc một Class nào đó. Và một Object là một thể hiện của Class. Tất cả các Object thuộc về cùng một Class có cùng các thuộc tính và các phương thức.

Syntac :

ClassAobj=newClassA();

Vậy thực chất Java đã làm những gì?

  • Khi ta sử dụng new Example(), Java sẽ dành ra một số lượng vừa đủ ô nhớ trong bộ nhớ để lưu trữ các giá trị của một đối tượng Example và sau đó khởi tạo đối tượng đó.
  • Sau khi khởi tạo, Java sẽ lấy địa chỉ của vùng nhớ đó, gán vào cho “biến” obj. Biến obj được định nghĩa là kiểu Example, hay tức là nếu ta truy cập vào địa chỉ mà biến obj lưu thì vùng nhớ đó sẽ là vùng nhớ của một đối tượng Example.
blog12 - quochung.cyou PTIT

Điều cần nói

  • Hiểu đơn giản, một object trong Java thực chất như 1 cái điều khiển, đang được cài đặt cho 1 cái tivi thực chất ở trong đó. Khi ta bấm nút trên điều khiển, thực chất ta đang gửi tín hiệu đến tivi, và tivi sẽ thực hiện hành động tương ứng. Vậy nên, khi ta gọi hàm obj.printInfo(), thực chất ta đang gửi tín hiệu đến đối tượng obj, và đối tượng obj sẽ thực hiện hành động tương ứng.
  • Lúc này, cứ tạm nhớ nó chỉ là cái điều khiển, chứ không phải cái tivi thật sự, sâu hơn ta sẽ tìm hiểu tiếp ở dưới. (Phần equal và hashcode)

Từ khóa this

  • Từ khoá this trong Java được sử dụng để tham chiếu đến đối tượng hiện tại. Từ khoá this có thể được sử dụng để tham chiếu bất kỳ biến nào của đối tượng hiện tại.

Ví dụ:

classPerson {
  String name;

  Person(String name) {
    this.name = name;
  }
}

  • Trong ví dụ trên, ta có một class Person, trong đó có thuộc tính name. Ta muốn gán giá trị cho thuộc tính name, ta sẽ tạo một hàm khởi tạo, và trong hàm khởi tạo đó, ta sẽ gán giá trị cho thuộc tính name. Vì tên của tham số trùng với tên của thuộc tính, nên ta sẽ dùng từ khóa this để tham chiếu đến thuộc tính name.
  • Từ khóa this cũng có thể được sử dụng để tham chiếu đến các phương thức của đối tượng hiện tại.
classPerson {
  String name;

  Person(String name) {
    this.name = name;
  }

  voidprintInfo() {
    System.out.println(this.name);
  }
}

Access modifier

access modifier trong java - quochung.cyou PTIT

Access modifier là gì

  • Access modifier là một từ khóa trong Java, nó được dùng để chỉ định quyền truy cập của một thuộc tính hoặc một phương thức. Có 4 loại access modifier trong Java, đó là public, private, protected, default.
  • Bài này chỉ cần nói default, private, public

Public

  • Khi ta dùng từ khóa public với một thuộc tính hoặc một phương thức, thì thuộc tính hoặc phương thức đó có thể được truy cập từ bên ngoài class.
publicclassPerson {
  public String name;
  publicvoidsetName(String name) {
    this.name = name;
  }
}

Private

  • Khi ta dùng từ khóa private với một thuộc tính hoặc một phương thức, thì thuộc tính hoặc phương thức đó chỉ có thể được truy cập từ bên trong class.
publicclassPerson {
  private String name;
  privatevoidsetName(String name) {
    this.name = name;
  }
}

Protected

  • Khi ta dùng từ khóa protected với một thuộc tính hoặc một phương thức, thì thuộc tính hoặc phương thức đó chỉ có thể được truy cập từ bên trong class, hoặc từ bên trong các class con của class đó.
publicclassPerson {
  protected String name;
  protectedvoidsetName(String name) {
    this.name = name;
  }
}

Default

  • Khi ta không dùng từ khóa nào với một thuộc tính hoặc một phương thức, thì thuộc tính hoặc phương thức đó chỉ có thể được truy cập từ bên trong class, hoặc từ bên trong các class cùng package với class đó.
publicclassPerson {
  String name;
  voidsetName(String name) {
    this.name = name;
  }
}

Tại sao cần dùng access modifier

  • Khi ta dùng access modifier, ta có thể kiểm soát được quyền truy cập của các thuộc tính và phương thức. Ví dụ, ta có một class Person, trong đó có thuộc tính name. Ta muốn thuộc tính name này chỉ có thể được truy cập từ bên trong class Person, và không thể được truy cập từ bên ngoài class Person. Vì vậy, ta sẽ dùng từ khóa private với thuộc tính name.
publicclassPerson {
  private String name;
}

Điều cần nói

  • Trong thực tế, ví dụ như 1 cái ô tô, bên trong có rất nhiều cấu kiện tinh vi. Khi ta khởi động xe, các cấu kiện hoạt động với nhau, ta không biết chúng làm gì, làm như nào, chỉ biết là khi bật xe, thì xe chạy.
  • Các cấu kiện đó là những thứ “private”, chỉ bên trong một class “Oto” với nhau mới biết, bên ngoài không biết. Điều này giúp cho việc sử dụng xe dễ dàng hơn, thứ bên ngoài không cần biết quá nhiều về xe bên trong.

Getter và Setter

Getter

  • Getter là một hàm dùng để lấy giá trị của một thuộc tính. Ví dụ, ta có một class Person, trong đó có thuộc tính name. Để lấy giá trị của thuộc tính name, ta sẽ tạo một hàm getName().
classPerson {
  String name;

  String getName() {
    return name;
  }
}

Setter

  • Setter là một hàm dùng để gán giá trị cho một thuộc tính. Ví dụ, ta có một class Person, trong đó có thuộc tính name. Để gán giá trị cho thuộc tính name, ta sẽ tạo một hàm setName().
classPerson {
  String name;

  voidsetName(String name) {
    this.name = name;
  }
}

Tại sao cần dùng Getter và Setter

  • Khi ta dùng Getter và Setter, ta có thể kiểm soát được quyền truy cập của các thuộc tính. Ví dụ, ta có một class Person, trong đó có thuộc tính name. Ta muốn thuộc tính name này chỉ có thể được truy cập từ bên trong class Person, và không thể được truy cập từ bên ngoài class Person. Vì vậy, ta sẽ dùng từ khóa private với thuộc tính name, và tạo một hàm getName() và setName() để lấy và gán giá trị cho thuộc tính name.
classPerson {
  private String name;

  String getName() {
    return name;
  }

  voidsetName(String name) {
    this.name = name;
  }
}

Điều cần nói

  • Hiểu đơn giản hơn, khi để tất cả thuộc tính private và chỉ getter setter public, vô hình chung ta sẽ dẫn mọi truy cập vào 1 class chỉ còn 1 con đường, đó là getter và setter
  • Điều này giúp ta có được “quyền kiểm soát”, “control” ở 1 class đó, ta có thể kiểm soát được các giá trị được gán vào thuộc tính, và các giá trị được lấy ra từ thuộc tính.
  • Ví dụ, ta có một class là “NhaHang”, nó có một phương thức lấy số điện thoại, tuy nhiên ta muốn khi lấy số điện thoại, ta sẽ chuẩn hoá lại giá trị đó, ví dụ như thêm dấu +84 vào đầu, hoặc thêm dấu – vào giữa, hoặc thêm dấu cách vào giữa, hoặc thêm dấu ngoặc vào đầu cuối, … Vậy nên, ta sẽ tạo một hàm getter, và trong hàm getter đó, ta sẽ chuẩn hoá lại giá trị trước khi trả về.
class NhaHang {
  private String soDienThoai;

  String getSoDienThoai() {
    // Chuẩn hoá lại số điện thoạireturn soDienThoai;
  }

  void setSoDienThoai(String soDienThoai) {
    this.soDienThoai = soDienThoai;
  }
}

  • Như này, ta đảm bảo số điện thoại được lấy ra dùng cho class khác luôn là số điện thoại chuẩn hoá, không cần phải chuẩn hoá lại nữa. Vì cách duy nhất để lấy số điện thoại ra là dùng hàm getter, và hàm getter đã chuẩn hoá rồi.
  • Tương tự với setter, giả sử ta có một class ConNguoi với thuộc tính chiều cao. Chiều cao thì không thể âm được, vậy nên ta sẽ tạo một hàm setter, trong đó ta sẽ kiểm tra giá trị trước khi gán vào thuộc tính.
class ConNguoi {
  private int chieuCao;

  void setChieuCao(int chieuCao) {
    if (chieuCao < 0) {
      this.chieuCao = 0;
    } else {
      this.chieuCao = chieuCao;
    }
  }
}

  • Điều này đảm bảo tính toàn vẹn của dữ liệu, giúp cho mọi thứ chính xác và chặt chẽ
  • Ngoài ra, nó cũng giúp tăng tính bảo mật, không lộ quá nhiều thông tin bên trong class ra bên ngoài, mà mọi thứ chỉ có thể truy cập, sử dụng qua các phương thức ta đã định nghĩa sẵn. (Tính chất này còn được gọi là encapsulation, đóng gói trong OOP)

Từ khoá Static

Static là gì

  • Static là một từ khóa trong Java, nó có thể được dùng với biến, hàm, class. Static có nghĩa là tĩnh, tức là nó chỉ tồn tại ở một vị trí duy nhất, tồn tại ngay cả khi chưa tạo ra đối tượng. Vì vậy, khi ta dùng static với biến, hàm, class, ta có thể gọi chúng mà không cần tạo ra đối tượng.
  • Ví dụ, trong các thư viện Java, ta thường thấy các hàm, biến, class static. Vì vậy, ta có thể gọi chúng mà không cần tạo ra đối tượng.

Math.sqrt(2);

Từ khoá static với biến

  • Khi ta dùng static với biến, thì biến đó sẽ được tạo ra ngay cả khi chưa tạo ra đối tượng. Vì vậy, ta có thể gọi biến đó mà không cần tạo ra đối tượng. Biến static này là tồn tại duy nhất, tức là nó chỉ có một giá trị duy nhất, và nó sẽ được sử dụng chung cho tất cả các đối tượng.
class Person {
  static int count=0;

  Person() {
    count++;
  }
}

Hàm main static

  • Hàm main() là một hàm static, nó được gọi khi chương trình bắt đầu chạy. Ví dụ:
public classHelloWorld {
  public staticvoidmain(String[] args) {
    System.out.println("Hello World");
  }
}

Lí do hàm main là static

  • Như ta biết, để gọi 1 hàm từ 1 đối tượng, ta phải tạo ra 1 đối tượng đó trước.
  • Vậy nếu hàm main không phải là static, thì ta phải tạo ra 1 đối tượng của class HelloWorld trước, rồi gọi hàm main từ đối tượng đó.
  • Vì vậy, hàm main static thường xuất hiện ở class khởi nguồn. Như vậy, Java có thể gọi thẳng hàm main luôn mà không cần khởi tạo class trước đó.

Lí do hàm static chỉ có thể gọi hàm static khác

  • Tuy nhiên, 1 hàm static chỉ có thể gọi các hàm static khác, và chỉ có thể truy cập các biến static khác. Lí do là vì, khi ta gọi hàm static, ta không cần tạo ra 1 đối tượng, vậy nên ta không thể truy cập các biến không phải static của đối tượng đó được. Và vì ta không tạo ra 1 đối tượng, nên ta không thể gọi các hàm không phải static của đối tượng đó được.
  • Cứ tưởng tượng đơn giản, các hàm, biến, class static luôn chỉ tồn tại độc nhất, tồn tại ngay cả khi chưa tạo ra đối tượng. Vì vậy, các hàm, biến, class static luôn có thể được gọi mà không cần tạo ra đối tượng. Nếu mà 1 hàm static gọi 1 hàm không static, thì lỡ đâu hàm không static đó lại cần phải truy cập đến các biến không static, thì làm sao mà truy cập được, vì chưa tạo ra đối tượng mà.

Về equal và hashcode, ==

  • Trong Java, có 2 cách để so sánh 2 đối tượng với nhau, đó là so sánh bằng toán tử == và so sánh bằng hàm equal(). Tuy nhiên, 2 cách này lại có 2 cách hoạt động khác nhau.
  • Khi ta so sánh 2 đối tượng bằng toán tử ==, ta đang so sánh 2 địa chỉ của 2 đối tượng đó. Nếu 2 đối tượng đó có cùng địa chỉ, thì toán tử == sẽ trả về true, ngược lại thì trả về false.
  • Khi ta so sánh 2 đối tượng bằng hàm equal(), ta đang so sánh 2 nội dung của 2 đối tượng đó. Nếu 2 đối tượng đó có cùng nội dung, thì hàm equal() sẽ trả về true, ngược lại thì trả về false.
  • equal() dùng để so sánh nội dung của 2 đối tượng. (Lưu ý: toán tử == sẽ là so sánh giá trị của 2 biến đó, hay chính xác hơn là so sánh địa chỉ, chứ không phải so sánh nội dung)
  • hashcode() dùng để xác định vị trí của đối tượng đó trong một bảng băm (cấu trúc dữ liệu mà đa số sẽ sử dụng)
- quochung.cyou PTIT
f0055 06 - quochung.cyou PTIT
f0057 01 - quochung.cyou PTIT
  • Vì như đã dạy ở trên, ta khi tạo 1 biến, thì thực chất nó chỉ là 1 tham chiếu
  • Vì vậy, các String có cùng 1 giá trị, dùng == để so sánh sẽ trả về false, vì chúng có các tham chiếu khác nhau
  • – Ta có thể thấy, khi gán 1 String khác, nếu không dùng new gán vào string cũ, chúng sẽ bằng nhau vì chúng cùng tham chiếu vào 1 địa chỉ
String str1=newString("Hello");
String str2=newString("Hello");

System.out.println(str1 == str2); // false



```java

String str1 = new String("Hello");
String str2 = str1;

System.out.println(str1 == str2); // true

  • Để giải quyết vấn đề này, ta dùng hàm equal() để so sánh nội dung của 2 String
String str1=newString("Hello");
String str2=newString("Hello");

System.out.println(str1.equal(str2)); // true
int a=5;
int b=5;

Integer a1=5;
Integer b1=5;

Integer a2=100;
Integer b2=100;

Integer a3=500;
Integer b3=500;



System.out.println(a == b); // true 
System.out.println(a1 == b1); // true vì đây là tham chiếu, nhưng mà Java có cơ chế cache Integer từ -128 đến 127, nên khi gán 2 số từ -128 đến 127, chúng sẽ cùng tham chiếu đến 1 địa chỉ, nên == sẽ trả về true
System.out.println(a2 == b2); // true vì đây là tham chiếu, nhưng mà Java có cơ chế cache Integer từ -128 đến 127, nên khi gán 2 số từ -128 đến 127, chúng sẽ cùng tham chiếu đến 1 địa chỉ, nên == sẽ trả về true
System.out.println(a3 == b3); // false
System.out.println(a3.equal(b3)); // true vì đây là so sánh nội dung, không phải so sánh tham chiếu

String và String Builder trong Java

  • String là một class trong Java, nó được sử dụng để lưu trữ một chuỗi các ký tự. Một đối tượng String được tạo ra bằng cách sử dụng từ khóa new và có thể được khởi tạo bằng một chuỗi ký tự hoặc một đối tượng String khác.
  • Lí do là vì, khi ta tạo biến String mới, ta chỉ đang tạo 1 tham chiếu đến vùng nhớ của String đó.
  • String là một dạng immutable, tức là nó không thể thay đổi được. Vì vậy, khi ta thay đổi giá trị của String, ta đang tạo ra một String mới, và tham chiếu đến nó.
String str="Hello";

str = "World";

  • Để nhập vào String từ bàn phím, ta sử dụng hàm nextLine() của Scanner
Scanner sc=newScanner(System.in);

String str= sc.nextLine();

  • Ngoài ra String có rất nhiều hàm hỗ trợ, dạy chính có thể đảo qua vài hàm chính
String str="Hello";

str = str.toLowerCase(); // "hello"

str = str.toUpperCase(); // "HELLO"

str = str.replace("l", "L"); // "HeLLo"Stringstr1="Hello    "

str1 = str1.trim(); // "Hello"
  • String Builder là một class trong Java, nó được sử dụng để lưu trữ một chuỗi các ký tự. Một đối tượng String Builder được tạo ra bằng cách sử dụng từ khóa new và có thể được khởi tạo bằng một chuỗi ký tự hoặc một đối tượng String khác.
  • String Builder là một dạng mutable, tức là nó có thể thay đổi được. Vì vậy, khi ta thay đổi giá trị của String Builder, ta không tạo ra một String Builder mới, mà chỉ thay đổi giá trị của String Builder đó.
StringBuilderstr=newStringBuilder("Hello");

str.append(" World"); // "Hello World"

str.deleteCharAt(0); // "ello World"

str.delete(0, 4); // " World"

str.insert(0, "Hello"); // "Hello World"

str.reverse(); // "dlroW olleH"
  • Để nhập vào String Builder từ bàn phím, ta sử dụng hàm nextLine() của Scanner như String
  • Vậy StringBuilder khác String ở chỗ nào
Stringstr="Hello";

str = str + " World";

  • Khi ta thực hiện phép cộng chuỗi, ta đang tạo ra một String mới, và tham chiếu đến nó. Vậy nên khi ta thực hiện phép cộng chuỗi nhiều lần, ta sẽ tạo ra rất nhiều String mới, và tham chiếu đến chúng. Điều này sẽ làm tốn rất nhiều bộ nhớ, và làm chậm chương trình.
  • Vì vậy, khi ta cần thay đổi giá trị của chuỗi nhiều lần, ta nên sử dụng StringBuilder, để tránh tạo ra quá nhiều String mới.
  • Bảng so sánh
StringStringBuilder
String là immutableStringBuilder là mutable
Khi thay đổi ít lần, String tốn ít bộ nhớ hơnKhi thay đổi ít lần, StringBuilder không được hết khả năng và tốn bộ nhớ nhiều hơn (lí do bên dưới)
Khi thay đổi nhiều lần, String tốn nhiều bộ nhớ hơn và thời gian hơnKhi thay đổi nhiều lần, StringBuilder tốn ít bộ nhớ hơn và thời gian hơn
inttotal=50000;
Strings=""; 
for (inti=0; i < total; i++) { s += String.valueOf(i); } 
// 4828msStringBuildersb=newStringBuilder(); 
for (inti=0; i < total; i++) { sb.append(String.valueOf(i)); } 
// 4ms
  • Lí do StringBuilder có thể động như vậy vì nó sử dụng cơ chế mảng động, tức là khi khai báo 1 String độ dài 10, thực chất Java dành ra nhiều ô nhớ hơn để khi thêm độ dài mới vào tốc độ sẽ nhanh hơn (do không phải di chuyển toàn bộ vùng nhớ sang phần khác). Trong khi đó, String thực chất là một mảng tĩnh, khi khai báo 1 String độ dài 10, Java chỉ dành ra đúng 10 ô nhớ, khi thêm độ dài mới vào, Java sẽ phải di chuyển toàn bộ vùng nhớ sang phần khác, điều này sẽ làm chậm tốc độ xử lí.
  • Tuy nhiên, do dùng ô nhớ ít hơn, String tốn ít bộ nhớ hơn StringBuilder. Vì vậy, khi ta cần thay đổi giá trị của chuỗi ít lần, ta nên sử dụng String, để tránh tốn bộ nhớ.

Kĩ thuật mảng động: Nhân đôi mảng

  • Hiểu đơn giản, mảng luôn được khởi tạo số lượng phần từ nhiều hơn thực tế, để khi thêm phần tử vào mảng, ta không cần phải di chuyển toàn bộ mảng sang phần khác.
  • Điều này thực chất đang hi sinh bộ nhớ nhiều hơn để có hiệu quả thời gian tốt hơn trong đa số trường hợp.
image 4 - quochung.cyou PTIT
image 5 - quochung.cyou PTIT

7. Inner class

Inner class là gì

  • Inner class là một class được khai báo bên trong một class khác. Inner class có thể được khai báo là static hoặc non-static. Inner class có thể truy cập tất cả các biến và phương thức của class bên ngoài nó.
classOuterClass {
  intx=5;

  classInnerClass {
    inty=10;
  }
}

Inner class static

  • Inner class static là một class được khai báo bên trong một class khác, và được khai báo là static. Inner class static có thể truy cập tất cả các biến và phương thức của class bên ngoài nó.
classOuterClass {
  intx=5;

  staticclassInnerClass {
    inty=10;
  }
}

Inner class non-static

  • Inner class non-static là một class được khai báo bên trong một class khác, và không được khai báo là static. Inner class non-static có thể truy cập tất cả các biến và phương thức của class bên ngoài nó.
classOuterClass {
  intx=5;

  classInnerClass {
    inty=10;
  }
}

Tại sao cần dùng Inner class

  • Inner class có thể được dùng để tạo ra một class chỉ được sử dụng bởi một class khác. Ví dụ, ta có một class Person, trong đó có một class Address. Class Address này chỉ được sử dụng bởi class Person, nên ta có thể tạo ra một inner class Address.
classPerson {
  String name;
  Address address;

  classAddress {
    String street;
    String city;
    String state;
  }
}

9. Sử dụng class từ file khác trong Java

  • từ việc import Scanner, import ArrayList, import String, … có thể import bất kì class nào trong Java, và sử dụng nó trong class của mình.
import java.util.Scanner;

classPerson {
  String name;
  int age;

  voidinput() {
    Scannersc=newScanner(System.in);
    name = sc.nextLine();
    age = sc.nextInt();
  }
}

  • Tuy nhiên, có thể tự tạo ra các class của riêng mình, và sử dụng nó trong class của mình. Ví dụ có các Class như QuanLySinhVien, SinhVien, MonHoc, … thì có thể tạo ra các file QuanLySinhVien.java, SinhVien.java, MonHoc.java, … và sử dụng chúng trong class của mình.
import java.util.Scanner;

classSinhVien {
  String name;
  int age;

  voidinput() {
    Scannersc=newScanner(System.in);
    name = sc.nextLine();
    age = sc.nextInt();
  }
}


classQuanLySinhVien {
  SinhVien[] danhSachSinhVien;

  voidinput() {
    Scannersc=newScanner(System.in);
    intn= sc.nextInt();
    danhSachSinhVien = newSinhVien[n];
    for (inti=0; i < n; i++) {
      danhSachSinhVien[i] = newSinhVien();
      danhSachSinhVien[i].input();
    }
  }
}

  • Lưu ý, trong 1 file thì chỉ có thể có 1 class public, và tên của class đó phải trùng với tên của file. Ví dụ, trong file QuanLySinhVien.java, ta chỉ có thể có 1 class public, và tên của class đó phải là QuanLySinhVien.
// QuanLySinhVien.java
public classQuanLySinhVien {
  ...
}


[Java Memory 1] Escaping References trong Java, Call-by-value và sử dụng Defensive copying. Biến trong Java lưu thế nào

This entry is part 1 of 2 in the series Java Memory

Mở đầu: Tại sao cần học quản lý bộ nhớ trong Java

  • Đã bao giờ bạn gặp phải những câu kiểu “Nháy nút refresh ở màn hình desktop máy tính cho máy chạy nhanh hơn”, hay khởi động lại điện thoại, máy tính, chạy lại ứng dụng, web để dùng mượt hơn chưa?
  • Nếu có, khả năng cao bạn đã gặp phải việc một ứng dụng đã không quản lý bộ nhớ tốt, điều này khiến cho bộ nhớ ngày càng sử dụng nhiều, và khi đầy thì ứng dụng bị lag. Việc ứng dụng bị lag có thể do nhiều vấn đề như, như mạng chậm, cơ sở dữ liệu, … nhưng việc bộ nhớ bị dùng full thường chiếm một phần khá lớn trong vấn đề này.
  • Hiểu được cách Java quản lý bộ nhớ là một điều thiết yếu để tối ưu hệ thống.

Kiểu dữ liệu nguyên thuỷ và object

  • Kiểu dữ liệu nguyên thuỷ trong Java có thể kể đến byte, short, int, long, char, ….
  • Trong Java, các đối tượng tạo từ các class như Integer, Double, Float, … Hay từ những class mà tự bạn tạo ra, thì chúng ta đang không thực sự lưu giá trị của nó, mà ta đang lưu “địa chỉ” đến giá trị thực sự của nó
image 33 - quochung.cyou PTIT
  • Hãy thử đi từ từ lại vấn đề này, khi ta sử dụng các kiểu biến nguyên thuỷ, ví dụ khai báo như sau
byte x = 7
image 34 - quochung.cyou PTIT
  • Thì trong Java, bản chất biến x này đang giữ giá trị thực sự, giá trị bit (00000111)
  • Còn một biến Object, hay biến địa chỉ thì chúng ta như đang lưu một “điều khiển”, một tham chiếu địa chỉ đến giá trị thực sự
Dog myDog = new Dog();
image 35 - quochung.cyou PTIT
image 36 - quochung.cyou PTIT
  • Okay, mong là bạn đã hơi mường tượng được điều gì đó, tiếp theo ta sẽ đến hai khái niệm là Stack và Heap
image 37 - quochung.cyou PTIT
  • Trong bộ nhớ Java, ta thường nhắc tới 2 nơi lưu chính đó là Stack và Heap. Toàn bộ Thread trong hệ thống sẽ có các stack khác nhau, tuy nhiên share chung 1 heap
  • Cơ bản ở reference bên trên, hay cách lưu tham chiếu, thì giá trị thực sẽ luôn nằm ở heap
  • Các biến nguyên thuỷ trong Java nếu được khai báo dưới dạng local variable (biến nằm trong các hàm, …) sẽ được lưu vào stack, còn các biến lưu dạng toàn cục của 1 class (instance variable) sẽ được lưu vào heap.
  • Tương tự, các biến tham chiếu (reference) cũng được lưu tương tự, nếu chúng là các local variable, các biến nằm trong 1 hàm main, 1 phương thức, … chúng sẽ được lưu trong stack. (Lưu cái con trỏ trong stack), còn nếu nó là biến toàn cục class (instance variable), nó sẽ nằm trong heap (vì hiện tại, nó đang được lưu trong 1 giá trị object thực sự khác, hay là heap)
image 38 - quochung.cyou PTIT

Java Pass-by-Value, Call-By-Value

  • Tiếp theo, ta sẽ đi đến khái niệm pass-by-value, hay call-by-value trong Java. Hiểu đơn giản, khi ta truyền các biến như 1 tham số trong 1 hàm, ta chỉ đang truyền một bản “copy” của đối tượng ban đầu đó.
image 39 - quochung.cyou PTIT
  • Ví dụ như đoạn code sau, đoạn code sẽ vẫn in ra 5, giá trị ban đầu của a, vì khi ta truyền vào hàm kia, ta chỉ đang truyền một bản sao của biến a, có giá trị là 5, chứ không thực sự truyền a, nên a ở hàm main sẽ không bị thay đổi.
  • Tuy nhiên, ta lại sẽ có một vấn đề thường hay mắc lầm khác, Hãy thử xem đoạn code sau:
image 41 - quochung.cyou PTIT

  • Đầu tiên ta có một object John, với tuổi là 20, và tên là John
  • Sau đó ta chạy hàm change, nơi mà ta sẽ cập nhậtbiến tuổi truyền vào thành 90, còn tên của person truyền vào thành Michael
  • Bạn sẽ nghĩ là, okay, giờ ta đang truyền một bản sao thôi đúng không? Vậy thì tuổi vẫn giữ là 20, còn tên vẫn giữ là John
  • Nhưng kết quả thật bất ngờ, tuổi thì đúng là không thay đổi 20, nhưng tên thì cập nhật thành Michael. Tại sao lại như vậy?
image 42 - quochung.cyou PTIT
  • Ta hãy quay trở lại khái niệm vừa nhắc tới
  • Khi ta truyền một biến nguyên thuỷ, hay trong trường hợp trên là age, ta đang như tạo một bản sao của một tờ giấy bình thường, tờ giấy đó ghi số là 20, ta đưa cho người khác. Họ sửa chúng thành 90, 100 hay gì đi chăng nữa, thì tờ giấy ban đầu của ta vẫn là 20
  • Tuy nhiên, khi ta truyền một biến reference (tham chiếu) của Object person, ta đang truyền bản sao của “tham chiếu” của nó. Hay đơn giản là, ta đang tạo ra bản sao của một cái điều khiển, vậy thì cái điều khiển bản sao này khi đưa cho người khác, vẫn sẽ bật tắt được tivi ban đầu của mình.
image 43 - quochung.cyou PTIT

Escaping References

  • Ta sẽ đi sang vấn đề. Vậy thì điều này có thể ảnh hưởng điều gì?
  • Một trong bốn tính chất của OOP, Encapsulation – tính đóng gói, trong đó có thể kể đến việc, ta sẽ cho toàn bộ biến trong class thành private, ẩn chúng với bên ngoài, với class khác. Để thay đổi, hay tương tác với chúng, ta chỉ tương tác qua các phương thức public mà ta cài đặt. (Ví dụ như Getter, Setter)
  • Bạn có thể đọc thêm bài này để hiểu thêm: http://quochung.cyou/tai-sao-can-viet-getter-setter-cho-cac-class/
image 44 - quochung.cyou PTIT
  • Tuy nhiên ta đi đến một vấn đề mới. Vì vấn đề truyền tham chiếu, truyền điều khiển thay đổi phần gốc như trên, thì với một ví dụ như trên, ta đang để private Name của Person, tuy nhiên khi gán nó cho một biến ngoài sb bằng getter, rồi thay đổi nó, thì nó cũng thay đổi giá trị được giấu bên trong class gốc.
  • Điều này phá huỷ việc đóng gói của chúng ta, do ta bằng 1 cách nào đó, đã thay đổi được giá trị private, mà không cần đi qua các phương thức setter hay các phương thức cho phép thay đổi của class.
image 45 - quochung.cyou PTIT

Kĩ thuật defensive copying

  • Lúc này, có một solution được biết đến với tên defensive copying
  • Ta sẽ không muốn lưu một bản tham chiếu đến một cái điều khiển, nơi mà ta có thể thay đổi giá trị đang được ẩn. Mà các phương thức lấy các tham chiếu này ra, ta sẽ tạo hẳn 1 object mới, một bản sao chỉ copy giá trị của biến gốc, chứ không copy điều khiển đến biến gốc.
image 46 - quochung.cyou PTIT
  • Như ví dụ trên, ở hàm Getter, ta chỉ trả về một biến String Builder mới, chứa dữ liệu của name, nhưng không có khả năng thay đổi name gốc
image 47 - quochung.cyou PTIT
  • Vấn đề đã được giải quyết !